Edge & offline

Closer to you,
or not connected at all.

Filtering that runs before the application does, a queue that survives losing signal, and a device API that will probably refuse to run for you. Two of the four things on this page are live; the other two say so.

Offline, in this browser.

Read from your browser, not asserted by mine. “Works offline” means four different things across four browsers and a page that states it flatly is wrong in at least two of them.

Reading the offline layer…

Documents are never served from cache

Network first, always, for navigation. A service worker is the only thing on a website that can outlive a deploy, and a cached document referencing JavaScript chunks that no longer exist is a white screen with no way for the visitor to tell you and no way for you to push a fix — because the fix is served by the broken worker. Only content-hashed build assets are cached, where a stale entry is unreachable rather than wrong.

The outbox is IndexedDB, not localStorage

Not a preference. A service worker has no access to localStorage at all — it is synchronous, and workers are denied synchronous storage. IndexedDB is the only store both the page and the worker can see, which is what lets a message queued by the page be sent by a worker after the tab is closed.

A 4xx deletes the queued message

That looks wrong and is deliberate. A 400 means the server read the payload and rejected it, so retrying sends identical bytes to an identical judgement forever and the queue never drains. Only a network failure or a 5xx is retried, because only those might succeed unchanged.

Navigation preload

The browser starts the network request for a navigation in parallel with booting the worker. Without it every navigation waits for worker startup first, which is the usual way adding a service worker makes a fast site slower.

One ruleset, two edges.

The request filter is a pure function with no platform imports — no next/server, no node:, no Cloudflare globals. It takes a Request and returns a verdict, which is what lets Vercel’s edge run it in the live path today and a Cloudflare Worker run the identical module the day DNS moves. Neither reimplements the other.

What is live — and what shadows it

The filter runs in proxy.ts on every request to /api, /admin and /account, plus a hand-listed set of probe paths. It answers a probe with a 404 rather than a 403, on the reasoning that a 403 confirms something is filtering while a 404 is what a site lacking the file returns anyway.

On production you will get a 403, because this code never runs for those paths. Vercel’s own managed firewall mitigates them at the edge first — X-Vercel-Mitigated: deny — so the probe rule here is shadowed entirely. Verified after deploying, not assumed. It still does the work the platform does not: traversal, scanner user-agents and oversized headers on the dynamic surface.

Why the paths are listed one by one

A catch-all matcher is how this is normally done, and here it would drag every prerendered page through the proxy and cost the site its static generation. So the traversal, hostile-agent and header-size rules cover only the dynamic surface. That is a real limit of filtering inside the application instead of in front of it, and it is exactly what the Worker fixes.

The Worker is written, not deployed

edge/worker.ts and its wrangler config are in the repository and are one wrangler deploy from live. Putting Cloudflare in front of this site means moving DNS off Wix — the same blocked task that has held up transactional email since P5. Writing a Worker nobody runs and calling it shipped would have been the easier claim.

Canary routing needs to be in front

Choosing between two deployments is impossible from inside either one. The Worker buckets on a hash rather than a random draw, because assignment has to be stable — random per request puts one visitor on both versions during a single page load, with the document from one build and its chunks from another. Static assets are excluded from bucketing for the same reason.

WebUSB, and why it will not run.

Chromium only. Firefox declined to implement it — its position is that exposing arbitrary USB devices to web content is not a risk worth taking — and Safari has not shipped it. Secure context and a user gesture are required, and the browser mediates every device through a chooser this page cannot skip.

Checking for WebUSB…

The permission model is the interesting part. A page cannot enumerate what is plugged in: getDevices() returns only devices already granted to this origin, which on a first visit is empty. So there is no fingerprinting surface and no silent access — and the “list your devices” demo everyone expects cannot be built, by design.

The React Native app that is not here.

What the roadmap asked for

A React Native application. Shipping one means an Expo workspace, a second dependency tree several times the size of this one, and two app-store accounts — to deliver a portfolio that is already a website. The honest answer is that it would be a worse product and a much larger maintenance surface, not that it was too hard.

What is here instead

An installable PWA: a manifest with shortcuts, an offline shell, a sync-backed outbox, and web push that predates all of it. On Android that is a home-screen icon and a standalone window. On iOS it is the same, minus push until the user adds it to the home screen.

Where the roadmap is right

A PWA is not a native app. It has no background location, no widgets, no share-sheet integration, and on iOS it lives under a storage budget the browser can evict. If any of those mattered here, none of the above would substitute — they do not, and that is the whole argument.

The part that would transfer

Everything under lib/ that has no platform import: the circuit breaker, the request filter, the histogram maths, the outbox protocol. That is the actual reusable surface between a web app and a native one, and it is reusable because those modules were written without reaching for a runtime — not because a framework promised it.