Server load analysis

Document version 2026-05-29 · Context: 1300 ble_token_renew from a single guest during 4 hours of the owner being offline, which prompted a hunt for other sources of redundant HTTP and WebSocket traffic.

Already fixed in 6.61–6.63

#1 — HTTP retries with no backoff FIX

Files: HostService.kt:1424-1432, Vault.kt:213

private val pendingKeysRunnable = object : Runnable {
    override fun run() {
        if (Prefs.isHostRegistered && Prefs.getPendingHostKeys().isNotEmpty()) {
            Thread { syncPendingHostKeys() }.start()
        }
        pendingKeysHandler.postDelayed(this, 60_000L)
    }
}

syncPendingHostKeys takes ALL pending host keys and calls Api.keyCreatefor each. If the HTTP call fails, the key stays pending, and 60 s later the cycle repeats.

The runaway scenario

Fix: exponential backoff per key

В Prefs store per localId:

On each run of syncPendingHostKeys:

val waitMs = (30_000L * (1L shl attemptCount)).coerceAtMost(30 * 60_000L)
if (now - attemptAt < waitMs) return@forEach  // skip
// otherwise try
val ok = try { Api.keyCreate(...); true } catch (e) { false }
if (ok) { clear attempt_at/count } else { attempt_count++; attempt_at = now }

Progression: 30 s → 1 min → 2 min → 4 min → 8 min → 16 min → 30 min (capped). With a permanent failure that is one request every 30 minutes instead of one a minute. A thirtyfold reduction.

The same applies to Vault.retryPendingBundles() — the same pattern.

#2 — Stream snapshots are NOT our load SKIP

snap_url is the external address of a camera, NVR or HTTP source. The client goes to that URL directly through Coil or ExoPlayer; our server merely passes the encrypted data_cipher containing the URL inside the bundle. No frames pass through us.

If an owner sets snap_int=1 for 10 guests, their NVR sees 10 requests a second. That is load on their NVR, not on our server.

Optionally: floor the interface slider at three seconds. That is a question of interface, not infrastructure.

#3 — ble_renew scheduler SKIP

Already reined in by the 6.61 fixes:

With ten BLE objects a user sends ten WebSocket messages in a row, and the server runs ten case 'ble_token_renew' branches in a row. Trivial.

#4 — bumping overridesVersion FIX

File: GuestConn.kt — 7+ places.

AppState.overridesVersion = MutableState<Int>, a Compose signal meaning "the data changed, re-read remember". It is used in ActionCard:

val ovrVersion = AppState.overridesVersion.value
val (overrideLabel, avatarPath) = remember(action.key, ovrVersion) {
    Prefs.getOverride(action.key)   // SharedPreferences IO
}
val snapCfg = remember(action.key, ovrVersion) {
    if (action.source == ActionSource.HOST_OWN) Prefs.getSnapshot(action.numberId)
    else Prefs.getIncomingSnapshot(...)
}
val tpl = remember(action.key, ovrVersion) { loadTpl() }  // more IO

Every bump makes each card re-read the override, avatar, snapshot and template from SharedPreferences. A user with 20 cards means 80 SharedPreferences reads per bump.

Where the bumps are

LineContextNeeded?
L193after re-ingesting a bundle (no K_obj)yes
L355after parseNumbers in numbers_updateredundant when numbers have not changed
L411after an avatar is loadedyes
L558, L595after parseNumbers in the other handlersredundant when the data is identical

Where this loads the server indirectly

With a flapping WebSocket, every guest_ok → the handler re-parses numbers → bumps overridesVersion → 80 SharedPreferences reads × 20 cards × the guest_ok rate.

That is local CPU and I/O, not direct load on the server, but:

Fix: compare before bumping

Before bumping, compare the new JSON list of numbers with the previous one, by hash or by comparing the list of GuestNumberInfo). Identical → do not bump.

private var lastNumbersHash: Int = 0

private fun maybeUpdateNumbers(arr: JSONArray?) {
    val parsed = parseNumbers(arr)
    val newHash = parsed.hashCode()  // or a hash of the JSON serialisation
    if (newHash == lastNumbersHash) return  // nothing changed
    lastNumbersHash = newHash
    numbers = parsed
    publish()
    AppState.overridesVersion.value = AppState.overridesVersion.value + 1
}

numbers_update arrives regularly but rarely actually changes — an owner does not edit objects every minute. The optimisation removes about 90% of the redundant redraw waves.

Conclusion: what to fix

#1 (HTTP retries with no backoff) — direct HTTP load. A simple per-key backoff cuts 97% of the requests on troublesome networks.
#4 (bumping overridesVersion) — indirect, through battery and CPU drain. Compare-before-bump removes 90% of the recompose waves.
#2 and #3 are off the agenda: the stream comes from an external NVR or CDN, not through us, and the renew scheduler is already reined in by the 6.61 fixes.