
Offline reading for the pages you visit on this Astro blog — a service worker, 50 pages capped, no TTL
How measurements decided a service worker for an Astro blog: 287KB precached instead of 11.7MB of HTML, 50 pages capped, and no expiry date.
On this page
Introduction
I read this blog. That sounds odd for the person who writes it, but I go back to my own articles to look things up, and often that happens on a plane with no connection and a browser tab that has nothing in it. What I wanted was simple: open an article I had already read, with the wifi off, and have it be there.
I did not know whether that was possible. My first guess was that the text could be stashed in localStorage and read back later, and I asked for that to be checked rather than assumed. It cannot work, and the reason is structural: localStorage is unreachable from the network layer, so a navigation request never consults it. The only thing that can answer a navigation offline is a service worker holding responses in the Cache API.
What decided the rest was not preference. It was measuring the build. This article walks through the numbers that ruled out the obvious design, the four caches that came out of them, and four checks along the way that were confidently wrong.
localStorage cannot answer a navigation
The appeal of localStorage is that it is one line to write and one line to read. The problem is what happens when the browser goes to fetch a URL: it consults its HTTP cache and the network, and nothing else. There is no hook where a page’s own stored strings get offered as a candidate response.
Three other limits make it worse. It caps around 5MB, which one article here would nearly fill on its own. It stores strings, so an HTML document goes in as one long string with no headers, no status, and no content type. And it is synchronous, so reading a large entry blocks the main thread.
The Cache API is the opposite of all four. It stores Request to Response pairs, headers and status included, which is precisely what a navigation needs answering with. A service worker sits in front of the network as a proxy, stays alive after the tab closes, and can hand one of those stored responses back.
// A service worker can answer a navigation. Nothing else in the browser can.self.addEventListener('fetch', (event) => { if (event.request.mode === 'navigate') { event.respondWith(handleNavigation(event)); }});What the build actually measures
The instinct at this point is to precache the site and be done. Measuring .vercel/output/static/ is what stopped that.
| Bucket | Size | Files |
|---|---|---|
| Article HTML (73 pages, 3 locales) | 8.87 MB | 73 |
| Tag, index, search and privacy HTML | 2.81 MB | 109 |
_astro/ images |
22.81 MB | 489 |
pagefind/ |
2.20 MB | ~100 |
| Markdown twins | 1.42 MB | 73 |
| CSS, JS and the webfont | 0.21 MB | 19 |
Feeds, sitemaps, llms.txt, icons |
3.68 MB | ~20 |
| Total | 42 MB | 883 |
A single article page reaches 300KB of HTML. That is not bloat. The figures on this site are inline SVG components so their labels can be translated, which means the diagram ships inside the document rather than as a separate file. It is the right call for three languages, and it is also why “just precache the HTML” costs 11.7MB.
Pushing 11.7MB at a phone that asked for one page is a bad trade for a guarantee most readers never use. So the design inverted: precache the shell, and keep every page on the way past. Offline then means “everything I have already opened”, which is the case that actually comes up, and it costs no extra bytes because those responses had to be fetched to draw the page anyway.
The shell came out at 287KB across 27 entries: 14 stylesheets, 4 scripts, the webfont, the icons, and the three manifests. That number also settled a question I had raised earlier, which was whether readers would resent a site quietly downloading things. At 11.7MB the answer would have been yes. At 287KB a consent dialog costs more attention than the thing it protects, so there is no prompt, and the honest concession went somewhere it matters instead: navigator.connection.saveData. A reader with Data Saver on still gets the worker and still keeps what they read. They just never pay for the speculative fetch.
That concession is smaller than it sounds, and it is worth saying so rather than letting it read as universal. The Network Information API is not Baseline, and MDN marks it limited availability on the grounds that it does not work in some of the most widely used browsers. Where navigator.connection is missing the optional chain yields undefined, the guard does nothing, and the shell is fetched regardless. The concession is real on the browsers that implement it and inert everywhere else.
Four caches, and why one of them is never trimmed
I asked three questions about growth at this point: whether to cache only the locale a reader is actually in, whether entries should expire, and whether there should be a cap. The answers were yes, no, and yes.
Only the visited locale’s home page is precached. The stylesheets and scripts are shared across all three languages, so the only locale-specific items are the home pages and the offline pages. Precaching a language the reader will never open is exactly the speculation the whole design avoids.
There is no expiry date, because the Cache API has no expiry and faking one is worse than not having it. A TTL would mean maintaining a parallel table of timestamps purely to guess at staleness, while the build already invalidates precisely what changed: a deploy mints a new worker version and the shell is reconciled against it. Meanwhile every navigation revalidates in the background. That last point held for articles and was wrong for the pages that list them, which a later article corrects: listings had to ask the network first. A wall clock would evict pages a reader had deliberately kept, on a schedule unrelated to whether anything about them had changed.
Caps are the right lever instead, and they are sized from the measured averages: article HTML runs 121KB, _astro images 48KB.
| Cache | Holds | Cap | Ceiling |
|---|---|---|---|
oharu-pages |
Article HTML, as you read it | 50 | ~6 MB |
oharu-images |
Figures, as the page draws them | 200 | ~9.6 MB |
oharu-shell |
CSS, JS, font, icons, manifests | 120 | ~1 MB |
oharu-offline |
The three offline pages | never trimmed | ~60 KB |
Eviction is FIFO over insertion order, which is the only ordering the Cache API exposes. True LRU needs an IndexedDB side table to track access, and for a blog read roughly forward in time the two are indistinguishable. That is not worth shipping a database for.
The last row is the one worth pausing on. The offline pages sit in their own cache precisely because they are written first, and a FIFO trim deletes from the front. Left in the shell they would be the first entries evicted, which would mean the fallback page disappearing the longer someone used the site.
The offline test that passed against a running server
The first verification looked convincing and was worthless.
The agent set Chrome DevTools to Network: Offline, navigated to a cached article, and watched it render. Then it navigated to an article that had never been visited, and that rendered too, which is not something a cache-what-you-read design should be able to do. The probe that settled it was one line in the page:
try { await fetch('/robots.txt', { cache: 'no-store' }); }catch (e) { console.log('THREW: ' + e.message); } // THREW: Failed to fetchThe page genuinely had no network. The worker did.
Network.emulateNetworkConditions is applied per target, and the worker is a separate target from the page it serves.A service worker is a separate DevTools target, and Network.emulateNetworkConditions applies to the target you set it on. The page was offline; the worker was not; so the worker fetched each page live and handed it back, and every test passed for the wrong reason. navigator.onLine also stays true under that emulation, which removes the other signal you might have caught it with.
The fix is to stop emulating and take the server away:
lsof -ti:4321 | xargs killRe-run against a genuinely dead origin and the results mean something. A visited article rendered in full, 63 paragraphs with its stylesheet and the two images that had actually been fetched. An unvisited URL produced the offline page in the right language, status 503, with the requested URL still in the address bar.
A redirect check that could never fire
The code review found a crash, and it is the kind that only shows up when you go looking for it.
redirected is false on an opaqueredirect, so the guard testing it never ran and the response fell into a constructor that rejects status 0.A navigation Request carries redirect mode manual. That means a 3xx does not arrive as a followed redirect. It arrives as an opaqueredirect: type is 'opaqueredirect', status is 0, ok is false, and redirected is false. The original code tested exactly the field that is never true:
// Never true for a navigation. The response falls straight through.if (response.redirected) return Response.redirect(response.url, 302);
if (response.ok) { /* … */ }
return offlineResponse(url, response.status); // status === 0offlineResponse then reaches new Response(body, { status: 0 }), and the Response constructor only accepts 200 through 599. It throws RangeError, the rejection escapes respondWith, and the reader gets the browser’s own error page for a URL that would have loaded fine with no worker installed. Reproduced against a local server that 301s, this was net::ERR_FAILED; after the fix the same URL loads.
if (response.type === 'opaqueredirect' || response.status === 0) return response;One honest correction, because I was told the wrong thing about how urgent this was. The reasoning offered at the time was that Vercel answers a directory URL missing its trailing slash with a 308, so every slash-stripped link would break. Probing production afterwards, nothing redirects: /ja, /privacy, /ja/tags and even /…/index.html all return 200. The crash is real and was reproduced, but the trigger named for it does not fire on the current routing. What the fix actually buys is safety against a future redirect rule rather than a bug in today’s traffic, and those are not the same claim.
PNG bytes are not portable, and CI proved it
The build generates the home-screen icons from public/favicon.svg, and a --check mode was written to catch them drifting from their source. It compared the generated PNG bytes against the committed ones. It passed locally and failed in CI:
PWA icons are stale: public/pwa-icon-512-maskable.png — differs from 20575 bytes public/apple-touch-icon.png — differs from 6395 bytesLocally those two files are 20596 and 6399 bytes. The two icons that differ are exactly the two that go through sharp.composite(); the two produced by straight rasterisation match to the byte. libvips composites and encodes differently between macOS on arm64 and CI’s Linux on x64, so that check would have failed forever while reporting artwork that was completely fine.
What the check is actually for is catching someone editing the favicon and forgetting to regenerate. That is an input changing, so inputs are what it now compares: a hash of the SVG, a hash of the icon spec, and the real dimensions read back off each file.
{ "source": "public/favicon.svg", "sourceHash": "64655d5b744ddbff", "specHash": "ebf2f5ba3712cce2"}Dimensions are read from the files rather than trusted from the lock, so a truncated or hand-replaced PNG is still caught. And the check was tested by breaking it on purpose: tampering with sourceHash exits 1, regenerating exits 0. A check that has only ever been seen to pass has not been tested.
A TODO left on code that was already finished
The other three were checks that said “fine” when nothing was. This one ran the other way.
offlineLocaleFor decides which language’s offline page to serve, and it shipped carrying a TODO and twenty lines of notes describing the unprefixed-URL case as an open editorial question. It was not open. prefixDefaultLocale: false means the English pages are the ones at the root of src/pages/, so /blog/some-slug/ is not an address of ambiguous language. It is an English address, and the function was already returning the right answer for every URL the site can produce.
function offlineLocaleFor(url) { const prefix = url.pathname.split('/')[1]; return LOCALES.includes(prefix) ? prefix : DEFAULT_LOCALE;}What was actually open was a much smaller preference, and not a defect: whether to override the URL’s language with the reader’s, by reading navigator.language. That was decided against, on the grounds that a worker sniffing the browser would be the only thing on the site that guesses a locale, and would answer an English link with a Japanese notice for the many readers here who hold ja-JP while deliberately reading English. The marker came out and the reasoning went into the docblock, where it can be argued with rather than merely noticed as missing.
What shipped, and what is still unverified
The worker is live. Reading an article caches it, /api/likes/* and /pagefind/* never enter any cache, and the shell settles at 27 assets. The reader’s own home page is kept alongside the pages they have read rather than in the shell, and nothing arrives from the other two languages.
Then I put a phone in airplane mode and opened the site.

That screenshot settles something the desktop testing could not. The page picks between two wordings using navigator.onLine, and under DevTools emulation that property stays true, so the offline wording never appeared no matter how the network was throttled. A real radio switched off is the only thing that exercises it, and it chose correctly.

That is the thing this was built for, working on the device it was built for. The header, the language picker and the footer are all there, so the shell landed; the article and its image came out of the two runtime caches.
One question had been carried since planning without an answer, and the production deploy settled it. The Vercel adapter writes its own .vercel/output/config.json, and CI deploys with --prebuilt, so it was genuinely unclear whether a headers block in vercel.json would survive:
$ curl -sSI https://oharu121.com/sw.js | grep -i cache-controlcache-control: public, max-age=0, must-revalidateThat header exists nowhere but vercel.json, so it does merge. No post-processing step was needed after all.
Those two screenshots are Safari, with its address bar and tab count visible, so on their own they prove offline reading in a browser tab and nothing about an installed app. So the site went onto a home screen next.

Everything the manifest asks for was honoured. The icon is the seal rather than a screenshot of the page, and iOS’s squircle mask meets vermilion at every corner instead of the black wedges a transparent PNG would have produced. The Add to Home Screen sheet offered oharu, which is the manifest’s short_name. Launching it opens with no address bar and no tab bar, so display: standalone is being read. And installed from the English root it opens the English home page, which is start_url doing its job.
What is still open is the part that takes a week. Safari erases everything a site has stored after seven idle days, and a home-screen app is documented as the exception. Whether navigator.storage.persist() is actually granted there, and whether the cache survives eight days of not opening the app, are both untested. So is the saveData branch, a single guard with no honest way to emulate it.
Summary
The design here was not chosen. It was what remained after measuring a 42MB build and finding 11.7MB of it was HTML, because translatable figures live inside the document. Precaching the site was never affordable, so the shell is 287KB and pages are kept as they are read.
Three of the four things that went wrong shared a shape: a check existed, ran, and proved nothing. The offline test passed because the throttle never reached the worker. The redirect guard read a field that is always false on the response it was written for. The icon check compared bytes that two machines will never agree on. Each of those looked like verification and was not, which is a more expensive failure than having no check at all, because it also buys false confidence.
The fourth was the reverse. A TODO marker shipped on a function that already handled every URL the site can produce, so working code was labelled unfinished. It is gone, and what replaced it is the reason the rule is what it is.
The part I wanted does work. I put a phone in airplane mode, opened an article I had read earlier, and it was there. Installing it to the home screen works too, icon and name and standalone launch and all. What I cannot check yet is the version of the test that takes a week, which is leaving an installed copy alone long enough to find out whether iOS keeps what it stored.
References
- Using Service Workers, including the fetch event and the Cache API
- Response() constructor, whose status must be in the range 200 to 599
- Response.type, listing opaqueredirect and what it means for a navigation
- WorkerNavigator, the reason a service worker can read navigator.language
- WebKit: Updates to Storage Policy, the seven-day eviction and the Home Screen exception
- StorageManager.persist(), which prompts in some browsers
- NetworkInformation.saveData, marked limited availability rather than Baseline




