# 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.

- Source: https://oharu121.com/blog/astro-service-worker-offline-reading-visited-pages-cap/
- Published: 2026-08-20T09:05:14+09:00
- Tags: Astro, Web API, Web Performance, Service Worker, PWA, iOS

---
**Key takeaways**

- `localStorage` cannot serve a page offline. It is unreachable from the network layer, so a navigation request never consults it.
- Measuring the build killed the obvious design: 11.7MB of the 42MB output is HTML, so precaching the site was never affordable. The shell is 287KB and everything else is kept as you read it.
- No expiry date. A deploy already invalidates exactly what changed, so growth is bounded by entry caps instead: 50 pages, 200 images.
- A DevTools "Offline" test proves nothing about a service worker. The throttle applies to the page target; the worker keeps its own network access and answers from the live server.

## 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.

```js
// 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

*Figure — CacheLayout: The shell is capped rather than pruned, so HTML cached before a deploy can still find the stylesheet it references.*

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](/blog/astro-service-worker-stale-index-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:

```js
try { await fetch('/robots.txt', { cache: 'no-store' }); }
catch (e) { console.log('THREW: ' + e.message); }   // THREW: Failed to fetch
```

**The page genuinely had no network. The worker did.**

*Figure — TargetSplit: `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:

```bash
lsof -ti:4321 | xargs kill
```

Re-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.

*Figure — RedirectPath: `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:

```js
// 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 === 0
```

`offlineResponse` 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.

```js
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:

```text
PWA icons are stale:
  public/pwa-icon-512-maskable.png — differs from 20575 bytes
  public/apple-touch-icon.png — differs from 6395 bytes
```

Locally 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.

```json title="scripts/pwa-icons.lock.json"
{
	"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.

```js
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.

*Image: An iPhone in airplane mode showing the site's offline page, headed "You are offline", with Try again and Home buttons and the full site header above it*

*A URL that was never cached. The wording is the offline one, not the not-found one, which is the branch a DevTools test could not reach.*

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.**

*Image: The same iPhone still in airplane mode, showing a previously read article rendered in full with its thumbnail image and title*

*An article read earlier, served from cache with no radio. The thumbnail came along because the page had already fetched it.*

**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:

```text
$ curl -sSI https://oharu121.com/sw.js | grep -i cache-control
cache-control: public, max-age=0, must-revalidate
```

**That 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.

*Image: The installed icon on an iPhone home screen: a vermilion rounded square with a white cherry-blossom character, its corners filled rather than transparent*

*iOS masks the icon to its own rounded square. The corners are vermilion rather than black, which is why that PNG is generated opaque.*

**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](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers)
- [Response() constructor, whose status must be in the range 200 to 599](https://developer.mozilla.org/en-US/docs/Web/API/Response/Response)
- [Response.type, listing opaqueredirect and what it means for a navigation](https://developer.mozilla.org/en-US/docs/Web/API/Response/type)
- [WorkerNavigator, the reason a service worker can read navigator.language](https://developer.mozilla.org/en-US/docs/Web/API/WorkerNavigator)
- [WebKit: Updates to Storage Policy, the seven-day eviction and the Home Screen exception](https://webkit.org/blog/14403/updates-to-storage-policy/)
- [StorageManager.persist(), which prompts in some browsers](https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/persist)
- [NetworkInformation.saveData, marked limited availability rather than Baseline](https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/saveData)
