# My blog goes stale until the second visit — how I fixed it with the correct cache strategy

> An Astro service worker served every navigation from cache first, so new articles appeared one visit late. Listings now go to the network first.

- Source: https://oharu121.com/blog/astro-service-worker-stale-index-network-first/
- Published: 2026-08-21T00:25:54+09:00
- Tags: Astro, Web API, Web Performance, Service Worker, PWA

---
**Key takeaways**

- A stale article costs a reader a wording tweak they will never notice. A stale index hides the article they came for, so one caching strategy could never serve both.
- Nothing can be fresh on a visit that does not ask the network. The browser checks for a new service worker *after* it has handled the navigation, which kills every invalidate-on-deploy scheme.
- `AbortSignal.timeout` stays attached to the response body after the headers arrive, so a deadline added to bound the wait made staleness permanent on slow connections instead.
- A test server that never answers cannot tell "no answer" from "slow answer". That is why the first suite reported 7 of 7 passing against a worker that was broken.

## Introduction

I published an article, opened the blog, and it was not in the list. A refresh brought it up. That happened every time, and my guess was that the site had handed me a cached copy of its own front page. **[Offline reading](/blog/astro-service-worker-offline-reading-visited-pages-cap/)** had shipped two days earlier in 0.32.0, so the timing pointed straight at the service worker that came with it. It did: **the worker applied one caching strategy to every navigation, including the page whose only job is to list what exists.** This article covers why that strategy is correct for an article and wrong for an index, two fixes that cannot work, and a deadline that made the original bug permanent before a code review caught it.

## What the worker was doing on every navigation

**Stale-while-revalidate, without exception.** The cached copy is served immediately and the network copy is written back for the next time:

```js title="scripts/sw/service-worker.js"
const cached = await cache.match(key);
if (cached) {
	event.waitUntil(revalidate(request, key, cache));
	return cached;
}
```

**No route ever escaped that branch.** The file said as much, in a comment written when the worker was built: *"the reader can therefore be one build behind for one navigation, which on a blog is worth an instant page."*

That trade is a good one for an article. An article's bytes barely move once published, so serving a slightly old copy costs a reader a wording tweak they will never notice, and buys them a page that paints instantly. The trade is a bad one for the page that lists articles, because **staleness in an index does not mean "slightly old". It means the thing the reader came for is invisible.** The worker had no way to tell the two apart, so both went down the same path.

*Figure — FirstVisitAfterPublish: The same publish and the same visit. Serving the cached copy first means the fresh one arrives for a visit that has not happened yet.*

## Why refreshing looked like it fixed the problem

The cache was never frozen. It refreshed on every visit, and the refresh only ever helped the *next* one:

| | What the page showed | What the worker did behind it |
| --- | --- | --- |
| A new article is published | | |
| Visit 1 | The old list, new article missing | Fetched the new index, overwrote the cache |
| Visit 2 | The new list, article present | Fetched again, overwrote again |

So the site was not caching forever. It was permanently **one visit behind**, showing the state of the world as of the previous visit. Refreshing appeared to fix it because refreshing was simply visit 2.

Two things kept this from being obvious. **A plain reload does not bypass a service worker, only a hard reload does**, so the symptom read as a slow deploy rather than a caching fault. And **the only person who can notice an absent article is someone who already knows it exists**, which on a personal blog is one person.

## Two fixes that do not work

### Busting the cache when a deploy lands

The obvious clever fix is to keep stale-while-revalidate everywhere and simply drop the cached listings whenever a deploy mints a new worker. The worker already carries a `VERSION` that digests its own source and the bytes of everything it precaches, so a deploy could prune those entries on `activate` and the next navigation would miss the cache and go to the network.

**It cannot work, for a reason that has nothing to do with this codebase.** The browser checks `/sw.js` for an update *after* it has handled the navigation, not before. So the first visit following a deploy is served by the **old** worker no matter what the new one would do once it activates. That is the same two-visit shape, with a cache-invalidation scheme added on top of it.

*Figure — UpdateAfterNavigation: The update check runs after the navigation it would have fixed, so the visit following a deploy is always served by the previous worker.*

Generalised, that is the constraint every candidate fix sits under: **nothing can be fresh on a visit that never asks the network on that visit.** Network-first is not a preference here. **It is the only shape that fixes the defect.**

### Serving the cache and probing behind it

The second candidate was mine: serve the cached copy instantly, probe the network in the background, and update the page when the probe comes back. It is a real pattern and the agent argued against it on three grounds, all of which held up.

**The probe is the same round trip that network-first waits for**, so it buys roughly a tenth of a second on a healthy connection. On a connection that is up but not responding the probe hangs too, so the reader gets an instant page and never gets the update, which is **the original bug wearing better clothes**. And a blog index is a grid of tap targets with a dwell time of about two seconds, so re-rendering the list under someone already reaching for a card means they tap article A and land on article B.

**The pattern earns its place on a page that is long-lived and not made of navigation targets**, like a dashboard or a feed. An index is neither.

## Listings ask the network first

The fix splits the strategy by what a URL is *for* rather than by what it costs to fetch:

```js title="scripts/sw/service-worker.js"
function isListing(pathname) {
	const segments = pathname.split('/').filter(Boolean);
	if (LOCALES.includes(segments[0])) segments.shift();
	return segments.length === 0 || segments[0] === 'tags';
}
```

Two shapes qualify in any locale: the home page, which is the article index, and anything under `/tags`. Both are rewritten by every publish. Everything else is content, and content keeps stale-while-revalidate untouched, **which is what preserves the offline reading the worker was built for in the first place.**

**Matching on structure rather than a list of paths means a new locale needs no edit here.** `LOCALES` already carries every prefix that can appear first, and the default locale is unprefixed, so its home page is simply `/`. The search page is deliberately absent: its HTML holds no results, Pagefind's bundle does, and the fetch handler already leaves that on the network.

I chose this from a set of options the agent laid out, over the background-probe design I had suggested and over a version that raced the network against a timer.

## The deadline that bounded the wrong thing

Network-first introduced one genuine regression, and it was not the one the trade-off is usually framed around. **Being offline fails instantly**, because there is no radio and no request, so the cached copy appears at once. **A connection that is up but unresponsive does not fail at all.** A tunnel, a hotel captive portal or one bar of signal leaves the request hanging until the browser's own timeout, which is far longer than anyone will look at a blank page.

So I asked for a bounded wait, applied only when there is a cached copy to fall back to. The first implementation used `AbortSignal.timeout`, and a code review agent found what was wrong with it before it shipped.

**`fetch` resolves as soon as the headers arrive, but the abort signal stays attached to the body stream.** A deadline that keeps running therefore fires mid-download on a slow connection. `cache.put` rejects with `AbortError`, the catch hands back the stale copy, and the fresh response that genuinely arrived is discarded. Every visit, for as long as the connection stays slow.

That is worse than the bug the release existed to fix, and worse than the behaviour it replaced: **the old background revalidate had no deadline at all**, so a reader on a slow connection was stale for exactly one navigation and fresh on the next.

*Figure — DeadlineVsBody: Cancelling the timer when the headers arrive is what separates a bound on the wait from a bound on the download.*

The fix is a cancellable timer, cleared the moment the headers land:

```js title="scripts/sw/service-worker.js"
const controller = new AbortController();
const deadline = fallback ? setTimeout(() => controller.abort(), NETWORK_TIMEOUT) : null;

try {
	const response = await fetch(request, { signal: controller.signal });
	// Headers are in, so the wait this bounds is over. Anything still to come
	// is the body draining, which gets as long as it needs.
	if (deadline !== null) clearTimeout(deadline);
```

The deadline is also armed only when `cache.match` returned something. With an empty cache there is nothing to fall back to, and a first-time reader pulling a 112KB article over a slow connection would be handed the offline page for a request that was about to succeed.

## Why the first test suite passed on a broken worker

The verification drove a headless Chromium against a production build with the shipped worker, mutating the built HTML between visits to stand in for a deploy. **It reported 7 of 7 passing on the implementation described above, which was broken.**

**A server that never answers cannot distinguish "no answer" from "slow answer".** It never sends headers, so the case where headers arrive inside the budget and the body is still draining after it never occurs. The suite tested a hang, and the defect lived in a slow success.

The check that finds it sends headers immediately and drips 72KB of body over four seconds, then **asks the cache what it holds rather than asking the page what it painted**:

```ts
const stored = await page.evaluate(async () => {
	const cache = await caches.open('oharu-pages');
	const hit = await cache.match(new URL('/', location.origin).href);
	return hit ? await hit.text() : '';
});
```

Against the first implementation:

```text
FAIL  a slow body still lands in the cache
      served stale; cache NOT updated — staleness would persist
```

Against the cancellable timer:

```text
PASS  a slow body still lands in the cache
      served fresh; cache updated
```

## Verification

Eight checks, all passing, against the shipped worker:

```text
PASS  listing shows a new article on the FIRST visit
PASS  /tags/ shows new content on the FIRST visit
PASS  article keeps stale-while-revalidate
PASS  a 308 on a cached listing still redirects with the timeout attached
PASS  an unslashed article URL is answered from cache, not redirected
PASS  a hanging network falls back to cache within the timeout
PASS  a slow body still lands in the cache
PASS  home is readable offline after PRIME, without ever visiting it
```

The number that matters is not eight. It is what the same suite does against the **previous** worker, because a test that cannot fail proves nothing:

```text
FAIL  listing shows a new article on the FIRST visit
      home served a stale copy — the reported bug
FAIL  /tags/ shows new content on the FIRST visit
      tag index served stale
PASS  article keeps stale-while-revalidate
      first visit stale: true, second visit fresh: true
FAIL  home is readable offline after PRIME, without ever visiting it
      offline navigation rendered the offline fallback
```

**The one that passes in both runs is the point of the exercise.** Stale-while-revalidate on articles is the behaviour that must not change, so a check that went green only after the fix would mean the fix had taken something away.

The hanging-network case measured 2541ms against a 2500ms budget. `AbortSignal.timeout` was replaced rather than feature-detected away, which also removed a compatibility question: **it needs Chrome 103, Firefox 100 or Safari 16, while `AbortController` predates all three.**

## Summary

| | Articles | Listings |
| --- | --- | --- |
| Strategy | Stale-while-revalidate | Network-first |
| First visit after a publish | Cached copy, refreshed behind | Current copy |
| Cost of being stale | A wording tweak nobody notices | The article is invisible |
| Offline | Served from cache | Served from cache |
| Connection up but hanging | Served from cache | Cached copy after 2500ms |

Three things generalise past this blog. **Split a cache by what a URL is for, not by what it costs**, because an index and the content it indexes fail in different ways when they go stale. **A service worker update lands behind the navigation it would fix**, so any scheme that invalidates on deploy inherits a one-visit lag for free. And **a deadline on a `fetch` outlives the headers unless you cancel it**, which turns a guard against slow networks into a guarantee of stale ones.

The last one is the reason the code review was worth running on a change of about forty lines. The defect it found was not in the strategy, which had been argued over carefully, but in the three-line safety net added at the end to protect against a case nobody had measured.

## References

- [MDN on AbortSignal.timeout(), including that the signal aborts the request rather than only the wait for headers](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static)
- [Can I use: AbortSignal.timeout() support, Chrome 103 / Firefox 100 / Safari 16](https://caniuse.com/mdn-api_abortsignal_timeout_static)
- [MDN on the Request constructor, including the rule that a non-empty init downgrades mode navigate to same-origin](https://developer.mozilla.org/en-US/docs/Web/API/Request/Request)
- [MDN on the service worker lifecycle, including when the browser checks the worker script for an update](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers)
