web: cache the daemon runtime so returning visitors skip the download - #59
Merged
Merged
Conversation
jamaljsr
self-requested a review
July 29, 2026 22:53
jamaljsr
requested changes
Jul 30, 2026
jamaljsr
left a comment
Member
There was a problem hiding this comment.
I measured this one before reading it, so the numbers below are mine.
What I tested:
- Reproduced the described win on a throttled 50 Mbps link, cold then reload in the same browser context. Cold
wasmTotal3258 ms, warm 95 ms (wasmCacheRead38 ms plus a 56 ms compile reportingsource: "cache"), runtime usable 603 ms against 4814 ms cold. Cold is unchanged from the base, so the unawaited write isn't costing the load that fills the cache. - Confirmed the decompressed-bytes invariant exactly: the cache reported
bytes: 129328511, byte-for-byte the size ofwavewalletdk.wasm, against 19,929,133 for the.gz. It is definitively not storing the compressed form. - Reproduced the release-pruning demo in a browser. Seeding a previous release plus a bucket from an older schema, then letting an upgrade load happen, leaves exactly one entry under
wavelength-runtime-v1. - Counted runtime fetches per page load across four loads, on a correctly configured host and on a half-configured one. That's finding 2 below.
- 53 unit tests pass in
packages/web, andpnpm buildandpnpm typecheckare clean.
The mechanism works and the numbers hold up. Findings 1 and 2 are about what happens on host configurations other than the demo's.
jamaljsr
force-pushed
the
perf/web-runtime-cache
branch
from
August 5, 2026 19:08
1536ab9 to
e58276a
Compare
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. |
jamaljsr
force-pushed
the
perf/web-runtime-cache
branch
from
August 5, 2026 20:34
e58276a to
e9ca77f
Compare
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. |
In this commit, we add the storage layer that lets a returning visitor skip re-downloading the wasm module. No caller uses it yet; wiring the load paths up comes next. The module is around 20 MB compressed, and that turns out to be large enough that Chrome refuses to keep it in the HTTP cache. Reading Resource Timing across repeated loads of one deployment shows transferSize staying at the full body size on every single load, while a 1 MiB asset served with byte-identical headers drops to 0 after the first fetch. The headers aren't the problem: this reproduces with Cache-Control: public,max-age=31536000,immutable and a CDN cache hit. The browser just won't store an entry that big, so every load pays the full transfer. That transfer is essentially the whole startup cost. Serving the same module from localhost with the bytes already local, compileStreaming takes 127 ms and instantiate 7 ms, because V8 compiles Go wasm lazily. So ~3.4s of a ~3.6s wasm load is network, and it repeats forever. Cache Storage has no such size ceiling, so we keep the bytes ourselves. Worth noting that caching the *compiled* module isn't an option: a WebAssembly.Module survives structuredClone, but IndexedDB rejects it with "A WebAssembly.Module can not be serialized for storage", so bytes are the only durable form. Since we periodically cut new runtime releases, the cache has to not grow without bound. Asset URLs already carry the runtime version (<base>/<version>/wavewalletdk.wasm.gz), so a release changes the key rather than overwriting the old entry. We prune on every store, which leaves a wallet that's been through several upgrades holding one runtime instead of one per release it has ever seen. The bucket name carries a separate schema version so a change to what we store can abandon the old buckets wholesale. None of this is load-bearing. Cache Storage is missing outside a secure context, throws on property access in some privacy modes, and rejects writes once an origin is over quota, so every operation degrades to "no cache" rather than failing the wallet.
jamaljsr
force-pushed
the
perf/web-runtime-cache
branch
from
August 6, 2026 04:50
e9ca77f to
65be143
Compare
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. |
In this commit, we teach the main-thread loader to check Cache Storage before reaching for the network, and to stash what it downloads on the way past. A returning visitor now pays a disk read instead of a 20 MB transfer. The write isn't awaited. Filling the cache shouldn't slow down the load that fills it, so we clone the response, hand the original to instantiateStreaming, and let the put run alongside it. One invariant is worth calling out: the cache always holds decompressed wasm, whatever encoding it arrived in. On the HTTP-decompression path the transport already inflated the body, so the clone is what we want. On the buffered path (a host serving a plain .gz as application/gzip) we inflate it ourselves, so we store those bytes rather than the response. Without that, the warm path would read gzip out of the cache, fail to instantiate, and fall back to the network on every single load. Speaking of which, cached bytes that don't instantiate are evicted and treated as a miss. A truncated or otherwise broken entry would otherwise wedge the wallet for good, since nothing else would ever clear it. We do the same on the raw path, so a self-host serving uncompressed wasm gets the benefit too, and not just deployments on the compressed one.
In this commit, we mirror the cache-first load into the worker, which is where it actually matters: the daemon runs in a Web Worker by default, so the main-thread path we just wired up is the fallback rather than the common case. The worker ships as a standalone file that the consumer's bundler emits from new URL(), so it can't import from the package and the helpers are inlined here instead. That's the same reason it already mirrors the asset names literally rather than pulling in RUNTIME_ASSETS. Keep the two in sync; the comment on each side says so. Behavior matches runtime.ts exactly, down to storing inflated bytes on the buffered path and evicting cached bytes that fail to instantiate.
In this commit, we document the Cache Storage bucket, since it shows up as ~130 MB of origin storage and consumers will want to know what it is before they find it in devtools. The important points for someone sizing storage: it holds no wallet state, it keeps one runtime at a time, and it prunes on the first load after a runtime version bump. We also note on the hosting page that long-lived cache headers are still worth setting but won't get you browser caching for a module this size, which is the whole reason the SDK keeps its own copy. While there, we call out serving the .gz as application/wasm with Content-Encoding: gzip to get the streaming compile path, which wasn't written down anywhere.
jamaljsr
force-pushed
the
perf/web-runtime-cache
branch
from
August 6, 2026 05:56
65be143 to
f3a4a92
Compare
jamaljsr
approved these changes
Aug 6, 2026
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
In this PR, we cache the daemon's wasm module in Cache Storage, so a returning
visitor reads it off disk instead of pulling 19 MB over the network again. On a
50 Mbps link this takes the runtime load from 3,250 ms to 68 ms, and the whole
boot from 4,654 ms to 476 ms.
This is stacked on #58, which added the instrumentation the numbers below come
from. Review that one first.
The browser will not cache a module this large
The startup metric that dominates cold load is
wasmCompileInstantiate, so theobvious read is that we're compile-bound. We aren't. Serving the same module off
localhost with the bytes already local:
WebAssembly.compileStreamingWebAssembly.instantiateinstantiateStreamingCompilation is cheap because V8 compiles Go wasm lazily, deferring function
bodies until they're first called. Instantiation is cheap too, despite the module
carrying 100,000 data segments and 67 MB of initialized data. So ~3.4s of a ~3.6s
wasm load is just moving bytes.
That would be a first-visit cost if the browser kept them, and it doesn't.
Fetching the module repeatedly in one context and reading Resource Timing:
transferSizetransferSizeSame origin, same
Cache-Control: public,max-age=31536000,immutable, CDNreporting a hit. The 1 MiB control asset caches on the first fetch; the module
never does. Chrome simply won't store an HTTP cache entry that big, so every load
pays full transfer forever. That's also why a reload measured no faster than a
cold load (3,761 ms vs 3,571 ms) before this change.
What we do about it
Cache Storage has no such ceiling, so we keep the bytes ourselves and the wasm
load becomes a disk read. Worth noting what isn't possible: caching the
compiled module. A
WebAssembly.ModulesurvivesstructuredClone, butIndexedDB rejects it outright with "A WebAssembly.Module can not be serialized
for storage", so bytes are the only durable form.
The write isn't awaited. We clone the response, hand the original to
instantiateStreaming, and let the put run alongside it, so filling the cachedoesn't slow down the load that fills it.
One invariant matters for correctness: the cache always holds decompressed wasm,
whatever encoding it arrived in. On the HTTP-decompression path the transport
already inflated the body, so the clone is what we want. On the buffered path (a
host serving a plain
.gzasapplication/gzip) we inflate it ourselves andstore those bytes instead. Without that split, the warm path would read gzip out
of the cache, fail to instantiate, and fall back to the network on every load.
Cached bytes that don't instantiate get evicted and treated as a miss, since
otherwise a truncated entry would wedge the wallet for good with nothing to clear
it.
Releases don't accumulate copies
Since we cut new runtime releases periodically, the cache can't grow without
bound. Asset URLs already carry the version
(
<base>/<version>/wavewalletdk.wasm.gz), so a release changes the key ratherthan overwriting the old entry, and we prune on every store. A wallet that's been
through several upgrades holds one runtime, not one per release it has ever seen.
The bucket name carries a separate schema version so a future change to what we
store can abandon the old buckets wholesale.
Verified in a browser rather than just in unit tests: seed the cache with a
previous release plus a bucket from an older schema, then let an upgrade load
happen.
None of this is load-bearing. Cache Storage is missing outside a secure context,
throws on property access in some privacy modes, and rejects writes once an
origin is over quota. Every one of those degrades to "no cache", which is the
behavior we had before.
Measured
Against the demo on a throttled 50 Mbps link, cold then reload in the same
context:
The cached entry is 129,352,347 bytes, i.e. the decompressed module, which is the
invariant above holding.
Also worth flagging for whoever picks up startup work next: with the load path
fixed,
createRpcat ~6.4s is now the slowest thing a new user waits for,against ~1.2s for
unlockRpcon nominally the same key derivation. That gap iswhere the next win is, not here.
Testing
53 unit tests pass in
packages/web, covering the cache module directly (schemacleanup, pruning, quota rejection, unenumerable caches) and the loader paths
(cache hit skips the network, cold load stores, buffered path stores inflated
bytes, corrupt entry is evicted and refetched).
pnpm buildandpnpm typecheckare clean, and the Playwright smoke test passes against a worker built with the
change.
See each commit message for a detailed description w.r.t the incremental changes.