Compressing what stays in the store (native.storage.configure)¶
What you'll learn
How to decide — with a number, not by reflex — whether it is worth compressing what your app keeps in the browser, and how to turn it on without wiping what is already on your users' devices. 📏
Measure your own payload before turning this on
This page exists because the intuitive answer is wrong. The gain is not what the compression ratio suggests, because IndexedDB already compresses what it stores. Turning the codec on by reflex trades CPU for far less disk than it looks like.
Migration: whatever ≤0.122.0 wrote is still in localStorage
Up to 0.122.0, native.storage in Modes A and B fell through to
localStorage — that was the defect 0.123.0 fixed. From 0.123.0 on, reads
go to IndexedDB, so that old content is orphaned: it is not deleted, but
nobody reads it any more, and your app sees an empty store again.
A new app does nothing. An app already in the field migrates once, on the
artifact's page, before boot — reading localStorage and rewriting through
storage.put:
<script type="module">
import { dispatch } from "./client/native/index.js";
const MARK = "tw.storage.migrated.v1";
const LEGACY = ["notes", "draft", "cache"]; // YOUR app's keys
if (!localStorage.getItem(MARK)) {
let allOk = true;
for (const name of LEGACY) {
const content = localStorage.getItem(name);
if (content === null) continue;
const written = await dispatch({
kind: "native_call",
call_id: `migrate-${name}`,
capability: "storage.put",
args: { name, content },
});
allOk = allOk && written.ok;
}
if (allOk) localStorage.setItem(MARK, "1");
}
</script>
List your app's keys: Object.keys(localStorage) sweeps up what is not
yours too. And do not delete the original — in a profile where IndexedDB
will not open, the capability keeps writing into localStorage itself, put
rewrites the same key, and a removeItem afterwards would delete the value
you just saved. The mark is what keeps this from running on every boot.
What was measured¶
Real Chrome 150, driven by Playwright, virgin origin,
CompressionStream("deflate"). Each payload was written twice into separate
databases — once as a string, once as deflated bytes — with
navigator.storage.estimate() read before and after, waiting for the value to
settle.
| Payload | In memory | Deflate | On disk, no codec | IDB compressed it by |
|---|---|---|---|---|
| catalogue, 5,000 rows | 976.9 KB | 120.6 KB | 222.1 KB | 4.4× |
| very repetitive history | 539.1 KB | 21.4 KB | 64.1 KB | 8.4× |
| random base64 noise | 143.6 KB | 93.6 KB | 126.5 KB | 1.1× |
The codec is not competing with raw text: it is competing with the LevelDB under IndexedDB. So the real saving is what is left over:
| Payload | No codec | With codec | Real saving |
|---|---|---|---|
| catalogue, 5,000 rows | 222.1 KB | 122.1 KB | −45.0% |
| repetitive history | 64.1 KB | 22.6 KB | −64.8% |
| base64 noise | 126.5 KB | 95.0 KB | −24.9% |
And what it costs, with the CPU throttled 6× to approximate a weak device. The columns are what the codec adds to the path that already existed:
| Payload | read + (1×) | read + (6×) | write + (1×) | write + (6×) |
|---|---|---|---|---|
| mutation queue (28 KB) | +0.3 ms | +2.8 ms | +0.8 ms | +4.8 ms |
| catalogue (~1 MB) | +2.1 ms | +12.4 ms | +13.0 ms | +75.8 ms |
| catalogue (~4 MB) | +6.1 ms | +33.8 ms | +49.7 ms | +295.1 ms |
The conclusion, in one line¶
Reading is cheap; writing is what hurts. +12 ms per read to save 45% of 1 MB is a good trade. 295 ms to write 4 MB on a weak device is a dropped frame you can see.
| Turn it on if | Do not turn it on if |
|---|---|
| the app holds tens of megabytes of repetitive collection | the app holds drafts, a queue and preferences |
| writes are rare (syncing a catalogue once a day) | writes are hot (every interaction saves) |
| you measured your payload and the number worked out | you are enabling it "because compression is good" |
Turning it on¶
from tempestweb import native
from tempestweb.native.storage import CODEC_DEFLATE
async def prepare_store() -> None:
"""Turn the codec on and record what the browser managed to do."""
result = await native.storage.configure(codec=CODEC_DEFLATE)
print(result.requested, result.active, result.supported)
configure never raises for lack of support. It answers three fields:
| Field | What it says |
|---|---|
requested |
what you asked for |
active |
what the next writes will actually use |
supported |
whether this browser can run the requested codec |
Safari below 16.4 has no CompressionStream
On that device, configure(codec="deflate") answers
active="json", supported=False and the store keeps working with plain text.
A store that cannot compress is still a store; an exception here would be a
dead screen on a real device your app has to serve.
Turning it on and off wipes nothing¶
The point that makes the option safe: decoding is always on; only encoding is opt-in. A stored value carries the name of the codec that wrote it, so the reader never consults the current setting.
| Situation | What happens |
|---|---|
| Record written before the codec was on | still readable after turning it on |
| Record written with the codec, read after turning it off | still readable |
| Envelope naming a codec this browser cannot read | becomes a cache miss (None), not an exception |
| Corrupt bytes | become a cache miss, not an exception |
The first two rows were measured in real Chrome, writing a 565 KB catalogue:
written with codec json → stored as a string
written with codec deflate → stored as {$twcodec, bytes}, 21.6 KB
read the old record with the codec ON → intact ✅
read the compressed record with the codec OFF → intact ✅
Without those two, enabling the option would wipe the cache of everyone already in the field — silently.
What is out of scope¶
- The mutation queue is not compressed. It is small, hot and critical: it is
not where quota hurts, and it is where write latency shows most. The codec is
for
native.storage, which is where the large collection lives — including whatevertempestweb.querypersists. - The
localStoragefallback does not compress. It holds strings, not bytes; where IndexedDB is absent,activecomes back"json". - The store's schema does not change. This is a codec, not a migration.
Recap¶
- IndexedDB already compresses: the codec saves 45–65% of what is left, not 87%.
- Reading costs little (+12 ms/MB on a weak device); writing costs a lot (+76 ms/MB, +295 ms at 4 MB).
- Default
"json", opt-in vianative.storage.configure(codec="deflate"). - An unsupported codec falls back to
"json"and reports, never raises. - Turning it on and off is safe in both directions, because decoding is always on.