State performance and UI responsiveness¶
What the tests establish¶
The regression suite uses lists of 100,000 and 1,000,000 items and a projection performing five million arithmetic steps. Before the change, 100 reads decoded the large JSON value 100 times, and a busy transformation prevented a queued UI callback from executing. Both regressions failed against the previous implementation.
The implementation now prepares JSON and a detached decoded snapshot on the worker dispatcher for
operations and setAsync. Ordinary reads reuse that snapshot. selectAsync computes away from the
owner scope and retains the last completed result. Producers commit sequentially with no extra buffer,
so a source error does not discard a preceding emitted value. Replacement still rejects stale work.
Two kinds of tests serve different purposes:
commonTest/LargeStateTest: large-list decode counts, async writes and expensive projections, shared across targets. Deterministic assertions check work counts and results, not a machine-specific frame-time threshold. Browser stress tests use a finite 60-second Mocha timeout; the default two seconds is too short for the five-million-step JS workload.jvmTest/UiResponsivenessTest: a real single-thread UI executor and a separate worker. A latch holds a non-suspending transformation busy while a UI callback must execute. A second test verifies that serialization and decoding of 100,000 items never execute on that UI executor and do not repeat on reads. A third test holds an obsolete serializer busy and verifies that a replacement can finish without waiting for it, and that its stale result never overwrites the new state. Timings are printed into the test reports for diagnosis.
A local Windows/JDK 21 instrumented run produced:
| Items | Asynchronous save | 100 subsequent UI reads |
|---|---|---|
| 100,000 | 155 ms | 2.8 ms |
| 1,000,000 | 556 ms | 0.6 ms |
Encoding and decoding ran on workers, and the reads performed no additional codec calls. These are observations, not latency guarantees or release benchmarks. The gating tests passed after the change. Garbage collection, device contention, rendering and the platform's own saved-state lifecycle are outside these timings.
API choice¶
// Inside a ViewModel: the producer and transformation execute on the worker dispatcher.
fun refresh() {
val query = filter.get().orEmpty() // Capture saved state before switching contexts.
users.loadMapped(transform = { dtos -> dtos.map { dto -> dto.toUiModel() } }) {
repository.users(query)
}
}
// Direct large update; await completion with the returned Job when necessary.
val write = users.setAsync(items)
// Expensive projection; reading visible.value does not rerun filtering/sorting.
val visible = users.selectAsync(initialValue = emptyList<User>()) { values ->
values.orEmpty().filter { it.active }.sortedBy { it.name }
}
The owner scope must remain main-thread confined. Codecs, producers and transformations must be safe for worker execution; capture UI inputs first, and keep submitted and returned snapshots immutable. Custom serializers may be invoked by overlapping cancelled/replacement work. Use stateless serializers or provide appropriate synchronization. CPU work that ignores cancellation may finish in the background, but its obsolete result is not committed. Add suspension/cancellation checks in long loops to stop it sooner.
Limits that must remain explicit¶
There is no universal promise that arbitrary code can never freeze the UI:
set, delegated property assignments, construction/restoration andselectare synchronous APIs. Initial restoration still decodes on the caller thread. Use compact saved models and asynchronous operations/projections for expensive updates. Raw writes to a privately owned key are unsupported.- On JVM/Android and Native,
Dispatchers.Defaultuses background threads. On browser JS/Wasm, changing dispatcher does not create a worker thread. A single huge synchronous serializer or mapper can still block the event loop. Split work with suspension points (for exampleyield()between batches), keep the saved payload compact, or offload a platform-specific operation to a Web Worker. A suspension between batches cannot preempt one expensive element or a synchronous JSON codec. - SavedStateHandle commits and platform save/restore are synchronous. Persisting huge lists can exceed saved-state size limits regardless of dispatcher. The stress test is not a recommendation to save 100,000 production records. Store IDs, filters, or a compact screen snapshot and reload the dataset from the Repository/cache.
selectAsyncstarts eagerly in its supplied scope and propagates errors there. Supply an appropriate scope or handle expected transformation errors explicitly; it does not invent a fallback policy.
The tests demonstrate worker separation and avoidance of repeated computation. They do not certify 16 ms frame times on every device or browser.
References: Default dispatcher, Main dispatcher, and cooperative yield.
Snapshot publication in 3.0.0¶
Holder flows project one immutable snapshot held by MutableStateFlow. Revision identities
make publication and built-in payload/result projections independent of deep payload equality.
Async preparation includes encoding, detached decoding and equality comparison on the worker.
selectAsync also publishes its projected value and compares outputs on the worker, under the
original scope Job; an expensive output equality check therefore does not migrate back to the UI.
If the owner changes while comparison runs, it is repeated on the worker before committing.
The handle receives prepared JSON before publication. There is no post-publication mutation.
Equal payloads reuse the previous immutable payload. User select functions and their output
equality run in their collector context and must remain cheap; selectAsync is the heavy-work API.
The platform still copies/marshals saved JSON synchronously. Pre-encoding does not eliminate that cost or Android's saved-state size limit. Restore decodes once on the owner to preserve immediate initial reads. Custom serializers and equality implementations must be safe for workers.
PublicationPerformanceTest measures 100, 1,000 and 10,000 integer records with live data/result
subscribers. It asserts codecs and payload equality stay off the owner and prints JSON byte size,
async latency, 100-read time and allocated bytes on the owner. JVM allocation counts come from
ThreadMXBean and include coroutine scheduling/measurement overhead. Large-list tests remain codec
stress tests, not recommendations for Android saved state.
Android frame comparison¶
Opt in with -PstateBenchmarks=true; ordinary CI/publication excludes the benchmark modules.
The app includes a frozen baseline from hotfix commit f483b58 and the new implementation in the
same APK, with identical UI, dependencies and integer fixtures. Run on a physical API 29+ device:
The parameterized benchmark measures both backends at 100, 1,000 and 10,000 rows, ten iterations
per case with full compilation. It renders a list, refreshes, merges, scrolls, rotates, backgrounds,
kills the background process and restores. FrameTimingMetric records frame costs; StateSave and
StateRestore trace metrics measure lifecycle costs. Logcat StateBenchmark records saved Bundle
bytes and process allocation deltas (including UI work). Keep JSON reports and Perfetto traces
from build/bench/test/outputs/. Benchmark build paths are shortened for Windows trace exports.
Compare frame CPU/overrun percentiles on the same device, refresh rate and power/thermal conditions. Repeat full runs to establish variability; a regression outside that observed variability blocks release. Keep device measurements separate from deterministic CI. Emulator runs only validate the harness and cannot certify hardware frame performance. No measured Android frame envelope is claimed until physical-device reports are available. iOS requires macOS for its test target.
Android harness validation (2026-10-01)¶
All six cases passed on an API 37.1 Android emulator: both backends at 100, 1,000 and
10,000 rows, one iteration per case. Rotation and background-process death restored the
payload without a new load (sequence=0). The complete Activity Bundle, measured through
Parcel, contained 2,272, 9,476 and 99,484 bytes respectively for these integer fixtures.
This validates the harness and platform save/restore path, not hardware frame acceptance.
For a quick harness run, pass stateBenchmarkIterations=1 as an instrumentation argument
and explicitly suppress the EMULATOR benchmark check. stateBenchmarkSmoke=true limits
that run to 100 rows. Do not suppress device checks for the physical-device acceptance run.
Current local JVM observations¶
A Windows/JDK 21 instrumented run with live payload/result collectors produced:
| Integer records | JSON UTF-8 bytes | Async write | 100 owner reads | Owner allocated bytes |
|---|---|---|---|---|
| 100 | 291 | 13.28 ms | 3.67 ms | 1,169,136 |
| 1,000 | 3,891 | 8.45 ms | 0.33 ms | 8,896 |
| 10,000 | 48,891 | 15.12 ms | 0.36 ms | 53,888 |
The first fixture includes cold startup overhead. These are diagnostic observations from
PublicationPerformanceTest, not warmed benchmarks or Android frame acceptance results.
All recorded codec and payload-equality calls ran outside the owner thread. Allocations include
the test fixture's JSON byte-count conversion and coroutine scheduling; they are not exclusively
holder allocations. Do not infer a production limit from these integer-only fixtures.