We’re working on bringing Naji — the horse behind the name — home to Jacksonville.Bringing Naji home to Jacksonville.Meet Naji

BlogengineeringChris Lane Jonesupdated

The hotel Wi-Fi died. The editor kept running.

Why an image editor that does its work inside the tab keeps going when the Wi-Fi doesn't. The engine runs in a back room, the work is saved on the device, and the internet was only ever carrying the extras.

Describes what ships in v8.85. Everything below is read out of the repository: Architecture.md, the code that saves your work, and the decision records it cites.

Listen narrated 12 min

0:0012:00

I was editing a photo in a hotel room, the kind of hotel where the Wi-Fi password is on a laminated card and the card is lying. Halfway through blurring a license plate the little Wi-Fi icon went gray. In most web tools that's the moment the spinner shows up and the last two minutes of your work turn into a question.

In Image Horse, nothing happened. Not "it coped." Nothing happened, because nothing in the editing path was ever using the internet in the first place. The engine that does the pixel work is a program running inside the browser tab, in its own back room. Your photos and every edit you make to them are stored in the browser's own filing cabinet. The internet was only ever carrying sign-in, cloud sync and the optional AI jobs, and those go dark on their own without taking the photo with them.

Here's my Azerbaijan fact for this post, and you're getting it whether you asked or not: the Caspian Sea, which Azerbaijan sits on, is legally a lake. It was always a lake. We called it a sea for so long that the name stuck. "Offline mode" is the same kind of name. There's no mode. The editor was never online.

Motel 5GHz: We'll keep the WAP on.

What the internet was ever for

The honest way to talk about offline is to list what needs a connection and what doesn't, so I measured it instead of arguing it. I served the real production build with every outside server blocked, and it booted to the same screen: the same 166 characters of UI text, canvas present, nine buttons down to eight. The missing one was sign-in (ADR-049).

With the tab open and the network gone.
Keeps workingGoes dark
The engine: every tool, every filter, every layer, undo and redoSign-in
Open a file, paste, new canvas, the gallerySync to your other devices
Your originals, edits and history, saved in the browser and surviving a reloadThe AI jobs that run on a server (background removal, restore)
Export: PNG, WebP, JPEG, and stripping the location data out of a photoSample Images (they live on ufs.sh)
AI Rename with the on-device model, once its 21.8 MB has loaded one timeGoogle Fonts (the system font steps in)

The right-hand column is short because logged-out was never a lesser version of the app. The account and sync pieces are optional even at build time; with no keys set, the whole thing runs on your device, and the docs say in so many words that this is a supported path and not a degraded one. Offline Image Horse is that path with the sign-in button missing.

FIG 1The cable goes mid-stroke. Strokes still reach the engine, the picture still reaches the screen, saves still reach the filing cabinet; none of those ever left the tab. Sync stops, sign-in stops, and the far side dims. When the cable comes back, the held-up sync goes out.

The engine lives in the tab

Since v8.32 the engine, stamp_tool, 814,432 bytes of Rust compiled to WebAssembly, has run in a Web Worker. If that phrase means nothing to you: a browser tab normally has one employee, and that one employee answers every click, draws every pixel and does every calculation. A Web Worker is a second employee in a back room who can do the heavy lifting while the first one keeps the counter open. The last post was about how the engine got into the back room. The part that matters here is simpler: the back room has its own memory and draws straight onto the screen through a window that was handed to it once. No server anywhere in the chain.

That matters for the network for the same reason it mattered for the back room. A single HD frame of a photo is 7.9 MiB. Any editor that does its work on a server has to ship something like that, squashed down, every time you change something, and on two bars of hotel signal that is the whole product. Here the pixels don't travel. A stroke goes in, a picture comes out, and the longest trip anything takes is from one side of the tab to the other, in 0.100 ms.

The original plus the receipts

An engine that keeps running is half of it. Laptops get closed, tablets get locked and put down, batteries die. Offline only counts if the work is still there afterward, and that's a storage question, not a speed question.

Image Horse is local-first in the literal sense. Three things go into the browser's filing cabinet, IndexedDB, and all three land there without a network:

The local copy is the truth. The cloud, if there is one, is a replica.

What is on the device, and how it got there.
StoreHoldsWritten when
OriginalsThe exact bytes you imported, filed under their own fingerprintOn import. Never changed after.
Working copyThe version the engine actually edits (2048 px on the long side)On import; refreshed on save. The fallback if the history can't be trusted.
Op logEvery edit, in order, plus a snapshot to replay them against, plus a note of where your undo cursor is~2 s after your last change, or at once when 25 edits pile up

The op log is the interesting one, and the easiest way to think about it is a bank statement. The statement doesn't store your balance. It stores the opening balance and every transaction since, and the balance falls out of that. The Dutch figured this out in the 1600s, when the Amsterdam exchange was trading shares of ships that were still at sea: what you trust isn't the cargo, it's the ledger. Image Horse keeps the ledger. The engine records every edit as it arrives; the save code waits for a two-second lull, packs everything up before it opens the file drawer (the browser slams that drawer shut if you pause with it open), and then writes the edits, the snapshot and the cursor note in one motion, so the disk never holds half a save.

// oplogPersistence.ts: pack first, then one write
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);

await db.transaction("rw", db.opLogs, db.keyframes, db.oplogManifests, async () => {
  await db.opLogs.put(chunk);
  await db.keyframes.bulkPut(dueKeyframes);
  await db.oplogManifests.put({ photoId, opCount: len, cursor, stale: false, … });
});

Opening the photo again is the same thing in reverse: the note, the edits and the snapshot come out, get checked against each other, and the engine replays the edits and parks the undo cursor where you left it. You come back with your undo history, not just your pixels. If any check fails, the answer is "no history" rather than a crash, and the working copy, which never stopped saving, carries you through. Two roads to the same photo, and neither one goes through a server.

FIG 2Yours to drive: play, scrub, or jump to a beat. Edits pile up in the back room, one write lands them in three drawers, the tab dies, and the next open replays the ledger into a fresh engine with the cursor where you left it.

The hardest building to be online in

I'm not planning to sell Image Horse to hospitals. But I keep coming back to them as the test case, because a hospital is the hardest network most software will ever meet, and not because the network is bad. It's split into zones on purpose, filtered hard, full of rooms that are shielded by design, and it goes down for maintenance at 3 a.m. because that's when the fewest people are on it. A tool that needs a connection in order to edit fails in every one of those places at once. This is what the back room and the ledger buy you there, and this part is really about every job where the signal is unreliable, which is a lot of jobs:

The failure, and what holds.
WhereWhat breaksWhat holds
Shielded roomWi-Fi stops at the door; the tablet came in anyway.Engine in the tab. Crop, blur, mark up, export, all local. Sync resumes in the hallway.
Walking the floorHopping between access points; the device gets locked and unlocked forty times a shift.Ledger saved within ~2 s of every change. A dead battery costs you two seconds, tops.
Locked-down networkOutside servers blocked at the firewall, no exceptions.The measured case above: same screen, sign-in gone, everything else there. Nothing in the edit path needs a domain on an allow-list.
Two screens, one roomThe same photo open on the wall display and the handheld.The two tabs talk to each other on the device, instantly, with no account and no signal.
PrivacyA photo with a face in it and GPS coordinates hidden inside the file.Pixels never leave the tab unless you sign in and ask. Metadata gets stripped on every export before the bytes leave the device.

The same column of answers works for a researcher on a boat, a clerk in a basement, an inspector in a plant that jams its own radio, and me in that hotel. The hospital is just the sharpest version, because there "please reconnect to continue" costs someone else's time.

Here's where offline tools usually cheat. When the connection comes back, a signed-in device doesn't just overwrite the cloud with whatever it has. Every sync change says which version it was based on, so a change you made offline can't land on top of newer work from your other laptop. Photo edits themselves stay out of that layer for now (ADR-061); syncing a ledger between two devices is a different problem, and it's written down as one instead of half-solved.

The last mile: the app itself

Everything above holds once the tab is open. The gap that's left is the open: today a fresh visit still downloads the app, ~3.6 MB across 9 files, engine included, from the server. 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 right there on the device. It's like owning the whole toolbox and still needing the power company to open the garage door.

The answer is a service worker that keeps a copy of the app on the device, and it's built: ADR-019 decided its shape in July, the code is in the tree, and five end-to-end tests cover it. It's shipping switched off for now, until those tests run in CI and the rollback has been rehearsed (ADR-049), because a wrong service worker is the worst bug a web app can ship: it leaves people stuck on an old version and nobody can tell. Most of the design is about what it refuses to cache.

What the cached copy does, and refuses to do.
PropertySettingWhy
What it cachesprecache only · zero runtimeCachingOnly the app's own files, which carry a fingerprint in their name and never change. Sign-in, sync and share links go straight to the network; a stale login or a stale document is worse than no cache.
UpdatesregisterType: "prompt" · no skipWaitingA new version waits for you to click Reload. Swapping the app out from under you mid-edit is the exact failure this app can't have.
Mismatch guardversion.json · never cached · no-storeOne tiny file is always fetched live and compared against the app's own version, at boot and again when the engine starts. A cached copy can never agree with cached code by accident.
Long sessionsregistration.update() hourlyBrowsers only look for a new version when you navigate, and an editing session never navigates.
Escape hatchVITE_ENABLE_SW=killA self-destruct version that wipes the cache, unregisters itself and reloads. The only safe way back off once "on" has shipped. Just turning the flag off isn't.

With it on, a repeat visit loads the app and the engine from the device and asks the network for exactly one small file. With no network, it asks for nothing and opens 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.

FIG 3Yours to drive. First visit fills the cache from the server. Repeat visits come from the cache, with version.json the one thing still fetched live. Offline, the server goes dark and the tab opens from the cache alone. Built and tested; off until ADR-049's gates are done.

See it yourself

Open the editor, drop in a photo, then cut the cord: DevTools → Network → Offline, or airplane mode. Keep working: paint, blur, add text, undo. Then watch the save 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, … all written with the network off

Reconnect, refresh, and the photo comes back with its undo history. (Refreshing while offline is the one thing that still needs a signal today; that's the download the cached copy exists to remove.) If you want to watch the back room specifically, set ih_engine_worker=0 in localStorage and the engine moves back to the front counter on the next load. The work stays on your device 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 internet carries the extras. The work never needed it.