← Blog · 2026-09-08
Making a website work in airplane mode
PDFAirlock's landing page makes a checkable promise: load the site once, switch on airplane mode, and everything still works — because your files are processed by your own browser, there's no server to need. A service worker makes that true. What I want to write down is how many subtly wrong versions of “true” I shipped on the way, because each one demoed perfectly.
Wrong version #1: cache-first, cache-forever
The first service worker did the classic runtime recipe: intercept fetches, serve from cache, fall back to network, store what you fetch. Offline demo: flawless. The bug surfaced when I changed the pricing page and realized returning visitors would never see it — cache-first with no revalidation means your site is frozen at whatever version each visitor saw first. The fix was stale-while-revalidate: serve the cache instantly (offline stays instant), refresh in the background, so updates land on the next visit.
Wrong version #2: offline works… if you rehearse
The embarrassing one, found only because I wrote a test for the exact wording of the promise: firstvisit → airplane mode → reload → merge a PDF. It failed. A runtime-caching worker only caches what got requested, and the page's own HTML and chunks were fetched beforethe worker took control; the PDF-engine worker script wasn't fetched at all until the first operation ran. So the demo worked beautifully for anyone who had already used a tool while online — which described me, every time I demoed it — and broke for the actual promise made to a first-time visitor.
The honest fix is precaching: at install time, the service worker fetches the complete asset list up front. But a modern build has content-hashed filenames you can't know in advance, so the list must be generated after the build — a script walks the output directory and emits a manifest the worker loads:
// sw.js
importScripts("/precache-manifest.js"); // self.__PRECACHE = [242 urls]
self.addEventListener("install", (e) =>
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(self.__PRECACHE)))
);That's ~5MB on first visit for this site — every page, chunk, worker and WASM binary. For most products that trade-off would be wrong; for one whose core promise is offline, it's the product.
Wrong version #3: correct code, wrong deployment
The manifest generator ran as an npm postbuildscript writing into the output folder. Locally: perfect. In production: the manifest 404'd, and offline quietly degraded back to version #2. Two platform behaviors conspired — the host invoked the framework's build directly (skipping npm lifecycle scripts), and its framework-aware pipeline reconstructed the deployment from intermediate artifacts, ignoring files added to the output folder afterwards. The durable fix was to opt out of the cleverness entirely: build to a static folder, tell the platform to serve that folder verbatim. Production now behaves byte-for-byte like the local test server, which retired a whole category of “works on my machine”.
The test is the spec
The reason any of this got caught is that the promise exists as an executable spec: a real browser visits once, waits for the worker to take control, goes offline, reloads, and merges two PDFs — and the suite runs against the production URL after deploys, not just against localhost. One practical note if you copy this: the worker's install (5MB of precache) takes real time over a real network, so the spec waits for navigator.serviceWorker.controller with a generous timeout before pulling the plug.
The result is a claim I can invite anyone to falsify: open a tool, flip on airplane mode, and see. That invitation — not the service worker — is the actual feature.
This post is from building PDFAirlock — PDF tools that run entirely in your browser and keep working with your wifi off. Free, no account, and every privacy claim is verifiable.