The PWA logo in white on a near-black card, its P, W and A cut as one connected angular wordmark with shared strokes

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:

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.

The first visit after a publish, under two strategiesTwo lanes describe the same visit to the article index just after a publish. The stale-while-revalidate lane serves the cached copy at once and fetches the current one only for the next visit, so the new article is missing. The network-first lane asks the network before answering, so the new article is present.A new article is publishedstale-while-revalidatewhat every navigation didServes the cached copyimmediatelyOld list.New article missingFetches the current copy, and stores it for the NEXT visitnetwork-firstwhat listings do nowAsks the networkbefore answeringCurrent list.New article presentSame publish, same visit, different answer
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.

The service worker update check lands after the navigationFour steps in order. A navigation arrives and the old worker answers it from cache. Only then does the browser check the worker script for an update, install the new one and prune the cache. A dividing line after the second step marks the point where the visit has already been answered.the visit is already answered1Navigationarrives2The OLD workeranswers itfrom cache3Browser checks/sw.js foran update4New worker installsand prunesthe cacheWhatever the new worker does on activate, it is one visit too late
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:

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.

A fetch deadline outlives the headers unless it is cancelledA time axis runs from the request to four seconds. Under AbortSignal.timeout the deadline keeps running past the headers and cuts the body off at 2500 milliseconds, so the cache write fails and the stale copy is served. Under an AbortController cleared when the headers arrive, the body runs to completion and the cache is updated.headers arrive, 2.0sdeadline fires, 2500msbody complete, 4.0sAbortSignal.timeoutabortedBody aborted mid-download. cache.put rejects, the stale copy is served, and the response that arrived is thrown away.AbortController, cleared on headerstimer clearedTimer cancelled the moment the headers land, so the body gets as long as it needs. Cache updated, fresh copy served.
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:

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:

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 persist

Against the cancellable timer:

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

Verification

Eight checks, all passing, against the shipped worker:

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:

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

Share this article