Describes what ships in v8.85. Everything below is read out of the repository — Architecture.md, the persistence code, and the ADRs it cites.
A wound-care nurse photographs a dressing change on a ward tablet, circles the margin, drops an arrow on the thing the consultant needs to see, and blurs the wristband. Halfway through, the tablet leaves the access point's reach — a lead-lined imaging suite, a lift, the far bay nobody's Wi-Fi survey covered. In most web tools that is the moment the spinner appears and the last two minutes of work become a question.
In Image Horse nothing happens. Not "it degrades gracefully" — nothing happens, because nothing in the editing path was ever on the wire. The engine that does the pixel work is a Rust program compiled to WebAssembly, running in a Web Worker inside the tab. The originals and every edit sit in the browser's IndexedDB. The network was only ever carrying sign-in, cloud sync and the optional AI jobs, and those go dark by themselves without taking the canvas with them.
This post is about how that falls out of the architecture rather than being bolted on — the worker, the op log on disk, and the one piece that is built but not yet switched on. Image Horse is an annotation tool, not a medical device, and nothing here claims otherwise. But the failure modes it was designed against are exactly the ones a hospital has.
Motel 5GHz: We'll keep the WAP on.
What the wire was ever for
The honest way to talk about "offline" is to list what needs a network and what doesn't. This was measured, not argued: the production build was served with every third-party origin blocked, and it booted to the same shell — same 166 characters of UI text, canvas present, nine buttons down to eight. The missing one was sign-in (ADR-049).
| Keeps working | Goes dark |
|---|---|
| The engine — every tool, every filter, every layer op, undo and redo | Clerk sign-in |
| Open a local file, paste, new canvas, the gallery | Convex sync to your other devices |
| Originals, edits and the op log in IndexedDB — surviving reload | Replicate AI jobs (background removal, restore) |
| Export — PNG on the Rust encoder, WebP/JPEG in the codec worker, EXIF/GPS scrub | Sample Images (they live on ufs.sh) |
| AI Rename with the on-device MobileCLIP model, once its 21.8 MB has loaded once | Google Fonts (falls back to system type) |
The right-hand column is short because the logged-out path was never a degraded mode. Convex and Clerk are optional at build time; with no keys set the app runs fully local, and the docs are explicit that this is a supported path and not a degraded one. An offline Image Horse is that path with the sign-in button missing.
The engine lives in the tab
Since v8.32 the engine — stamp_tool, 814,432 bytes of Rust compiled to WebAssembly — runs in a dedicated Web Worker. The last post was about how it got there without ever sending a frame across a thread boundary. The part that matters here is simpler: the worker owns its own WASM instance and its own linear memory, and the composite is drawn straight onto an OffscreenCanvas that was transferred in once. No SharedArrayBuffer, no server, no round trip anywhere in the render path.
That is a thread-boundary story, but it is also a network story, because the same property that made the pixels impossible to ship across a thread makes them pointless to ship across a network. A 1920×1080 RGBA frame is 7.9 MiB. Every editor that renders server-side has to move something like that, compressed, on every change — and on a ward with two bars of signal, that is the whole product. Here the pixels never move. Input goes in, a blit comes out, and the longest anything travels is from one side of the tab to the other, in 0.100 ms.
Truth is the original plus the op log
A worker that keeps running is half of it. A tablet on a ward gets locked, put down, and picked up by someone else; a battery dies mid-shift. Offline only counts if the work is still there afterwards, and that is a storage question, not a compute one.
Image Horse's storage story is local-first in the literal sense. Three things go into IndexedDB, and all three land there without a network:
The local copy is the truth. The cloud, if there is one, is a replica.
| Store | Holds | Written when |
|---|---|---|
| Originals | The bytes you imported, content-addressed by SHA-256, read through a Dexie adapter | On import. Never modified. |
| Working copy | The ≤ 2048 px document the engine actually edits, plus its archive | On import; refreshed by the save path. The fallback if the log can't be trusted. |
| Op log | opLogs chunks · keyframes as byte-exact PNG from the engine's own codec · an oplogManifests row with counts and the undo cursor | ~2 s after your last change, or at once when 25 ops pile up |
The op log is the interesting one. The engine records every edit in arrival order; oplogPersistence.ts watches each flush, debounces, encodes everything before the transaction opens (IndexedDB auto-commits an idle transaction, so you cannot await inside one), then commits chunks, keyframes and manifest in a single readwrite — so the disk never holds half a save.
// oplogPersistence.ts — encode first, then one transaction
const DEBOUNCE_MS = 2000;
const OPS_PER_FORCED_SAVE = 25;
const frames = await tool.oplog_encoded_ops(fromOp, len);
const png = await tool.oplog_keyframe_png(0); // engine codec, byte-exact
await db.transaction("rw", db.opLogs, db.keyframes, db.oplogManifests, async () => {
if (rewrite) { await db.opLogs.where("photoId").equals(photoId).delete(); /* …keyframes too */ }
await db.opLogs.put(chunk);
await db.keyframes.bulkPut(dueKeyframes);
await db.oplogManifests.put({ photoId, opCount: len, cursor, stale: false, … });
});Restore is the mirror image: manifest, chunks and the base keyframe come out, get validated against each other, and are handed to the engine's oplog_restore, which replays the ops and seeks the persisted cursor — so you come back with your undo history, not just your pixels. If any check fails, the answer is "none" rather than an exception, and the working copy — which never stopped writing — carries the resume. Two paths to the same document, neither of which involves a server.
Why this is the shape a hospital needs
Hospitals are the hardest network environment most software will ever meet, and not because the network is bad. It is deliberately segmented, aggressively filtered, full of Faraday cages by design, and it goes down for maintenance at 3 a.m. because that is when the fewest people are on it. A tool that treats connectivity as a precondition for editing fails in all of those places at once. Take them one at a time:
| Where | What breaks | What holds |
|---|---|---|
| Imaging suite | Shielded room. Wi-Fi stops at the door; the tablet came in with the patient. | Engine in the tab. Annotate, crop, blur, export — all local. Sync resumes in the corridor. |
| Ward round | Roaming between access points; the device is locked and unlocked forty times a shift. | Op log in IndexedDB within ~2 s of every change. A dead battery costs at most the last two seconds. |
| Operating room | Locked-down network: third-party origins blocked at the firewall, no exceptions. | The measured case above: same shell, sign-in gone, everything else present. Nothing in the edit path needs a domain on an allow-list. |
| Two screens, one bay | The same photo open on the wall display and the handheld; both need to agree on settings. | Cross-tab sync rides a BroadcastChannel — on-device, instant, no account, works offline. |
| Patient privacy | A photo with a face, a wristband and GPS coordinates in its EXIF. | Pixels never leave the tab unless you sign in and ask. Metadata scrub runs on every export path before bytes leave the device. |
The same column of answers applies to a field researcher on a boat, a court clerk in a basement, an inspector in a plant that jams its own radio. The hospital is the sharpest version because the cost of "please reconnect to continue" is measured in someone else's time.
One thing worth being precise about, because it is where offline tools usually cheat: when the wire comes back, a signed-in user's device does not simply overwrite the cloud. Sync is a compare-and-set on the revision the change was based on, so a mutation Convex queued while offline cannot land on top of newer work from another device. Photo edits are deliberately outside that layer for now (ADR-061) — replicating an op log is a different problem, and it is written down as one rather than half-solved.
The last mile: the shell itself
Everything above holds once the tab is open. The remaining gap is the boot: today a fresh navigation still fetches the app shell — ~3.6 MB across 9 assets, the engine among them — from the origin. Close the tab in the shielded room and you can't reopen it until you're back in range, even though every byte of your work is sitting in IndexedDB.
The answer is a precache-only service worker, and it is built: ADR-019 decided its shape in July, the code is in the tree, and five end-to-end tests exercise registration, precache contents, offline reload and the build-skew guard. It ships dark for now — activation is gated on running those tests in CI and rehearsing the rollback path, per ADR-049 — because a wrong service worker is the worst bug class a web app can ship: it strands users on a stale build invisibly. The design is worth walking through because most of it is about not caching things.
| Property | Setting | Why |
|---|---|---|
| Strategy | precache only · zero runtimeCaching | Build-hashed immutable assets are the only cache with a correctness proof. Clerk, Convex and share URLs pass straight through — stale auth or a stale document is worse than no cache. |
| Update | registerType: "prompt" · no skipWaiting | A new build waits for an explicit Reload. Swapping code under an active editing session is the exact failure this app cannot have. |
| Skew guard | version.json · never cached · no-store | Compared against the bundle's own hash at boot and at engine init — where stale WASM would start work. A cached copy can never agree with cached JS by construction. |
| Long sessions | registration.update() hourly | Browsers only check for a new worker on navigation, and an editing session never navigates. |
| Escape hatch | VITE_ENABLE_SW=kill | A self-destructing sw.js that wipes caches, unregisters and reloads. The only correct way off once "on" has shipped — never unsetting the flag. |
With it on, a repeat load serves the shell and the engine from Cache Storage and asks the network for exactly one small file. With the network gone, it asks for nothing and boots anyway.
The work is on the device, the engine is on the device, and the program that runs the engine is on the device too.
version.json the one thing still fetched live. Offline, the origin goes dark and the tab boots from the cache alone. Built and tested; dark until ADR-049's gates are done.See it yourself
Open the editor, drop in a photo, and then cut the cord: DevTools → Network → Offline, or airplane mode. Keep working — paint, blur, add text, undo. Then watch the persistence land:
// DevTools → Application → IndexedDB, or from the console:
const req = indexedDB.open("image-horse");
req.onsuccess = () => console.log([...req.result.objectStoreNames]);
// → opLogs, keyframes, oplogManifests, originals, … — written with the network offReconnect, refresh, and the document comes back with its undo stack. (Refreshing while offline is the one thing that still needs the wire today — that is the shell fetch the precache exists to remove.) To watch the engine specifically, ih_engine_worker=0 in localStorage puts it back on the main thread on next load; the work stays local either way — slower under load, never wrong.
Open the betaRead oplogPersistence.ts
Sources: Architecture.md · oplogPersistence.ts · ADR-024 · ADR-019 · ADR-049 · ADR-061 · swBoot.ts · MDN, Offline and background operation
The wire carries the extras. The work never needed it.