
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.
On this page
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 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:
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.
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.
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:
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.
The fix is a cancellable timer, cleared the moment the headers land:
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:
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:
FAIL a slow body still lands in the cache served stale; cache NOT updated — staleness would persistAgainst the cancellable timer:
PASS a slow body still lands in the cache served fresh; cache updatedVerification
Eight checks, all passing, against the shipped worker:
PASS listing shows a new article on the FIRST visitPASS /tags/ shows new content on the FIRST visitPASS article keeps stale-while-revalidatePASS a 308 on a cached listing still redirects with the timeout attachedPASS an unslashed article URL is answered from cache, not redirectedPASS a hanging network falls back to cache within the timeoutPASS a slow body still lands in the cachePASS home is readable offline after PRIME, without ever visiting itThe 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:
FAIL listing shows a new article on the FIRST visit home served a stale copy — the reported bugFAIL /tags/ shows new content on the FIRST visit tag index served stalePASS article keeps stale-while-revalidate first visit stale: true, second visit fresh: trueFAIL home is readable offline after PRIME, without ever visiting it offline navigation rendered the offline fallbackThe 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
- Can I use: AbortSignal.timeout() support, Chrome 103 / Firefox 100 / Safari 16
- MDN on the Request constructor, including the rule that a non-empty init downgrades mode navigate to same-origin
- MDN on the service worker lifecycle, including when the browser checks the worker script for an update





