An Astro service worker showed new articles one visit late — network-first for listings

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

Updated

The PWA logo in white on a near-black card, its P, W and A cut as one connected angular wordmark with shared strokes
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 pages whose only job is to list what exists. Listings now ask the network first.

This article covers why one strategy cannot serve both kinds of page, and the fetch deadline that nearly made the bug permanent on slow connections.

What the worker was doing on every navigation

Every navigation went through stale-while-revalidate. 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 escaped that branch. The comment written beside it said so: “the reader can therefore be one build behind for one navigation, which on a blog is worth an instant page.”

That trade suits an article. An article’s bytes barely move once published, so a slightly old copy costs the reader a wording tweak they will never notice, and buys them a page that paints instantly.

The page that lists articles pays a different price. A stale index hides the article the reader came for. The worker had no way to tell the two kinds of page 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 each refresh only helped the next one:

What the page showedWhat the worker did behind it
A new article is published
Visit 1The old list, new article missingFetched the new index, overwrote the cache
Visit 2The new list, article presentFetched again, overwrote again

The site stayed permanently one visit behind, showing the state of the world as of the previous visit. Refreshing appeared to fix it because a refresh was visit 2.

A plain reload does not bypass a service worker. Only a hard reload (Shift+reload) does, so a hard reload that shows the new article on the first try confirms the worker, not the server, is serving the old list.

The symptom also hides from everyone but the author. Only someone who already knows an article exists can notice it missing, and on a personal blog that is one person.

Two alternatives to network-first: invalidate on deploy, or probe behind the cache

Neither alternative makes the first visit after a publish fresh.

Busting the cache when a deploy lands

This one keeps stale-while-revalidate everywhere and drops 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. A deploy could prune the listings on activate, and the next navigation would miss the cache and go to the network.

The browser checks /sw.js for an update only after the current worker has handled the navigation. The Service Worker spec starts that check once the fetch event has run, so the first visit after a deploy is served by the old worker, whatever the new one would do on activate. Invalidating on deploy keeps the same one-visit lag.

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.

Every candidate fix sits under that constraint: nothing can be fresh on a visit that never asks the network on that visit. That leaves network-first as 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 an index defeats it:

  1. The probe is the same round trip that network-first waits for, so on a healthy connection it saves almost nothing.
  2. On a connection that is up but not responding, the probe hangs too. The reader gets an instant page and never gets the update, which is the original bug with a faster first paint.
  3. A blog index is a grid of tap targets that a reader leaves within seconds. 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, such as 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:

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] === "posts" || segments[0] === "tags";
}

Three shapes qualify in any locale: the home page, which lists the newest articles; anything under /posts, the full archive and its pagination; and anything under /tags. Every publish rewrites all three. Everything else is content and keeps stale-while-revalidate, which preserves the offline reading the worker was built for.

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 bundle on the network.

Bounding the wait, and why AbortSignal.timeout aborted the body

Network-first introduced one genuine regression. 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.

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.

fetch resolves as soon as the headers arrive, but the abort signal stays attached to the body stream. The Fetch spec’s abort steps error the response body even after the fetch promise has fulfilled. A deadline that keeps running therefore fires mid-download on a slow connection.

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.

cache.put then rejects with AbortError, the catch hands back the stale copy, and the fresh response that genuinely arrived is discarded. That repeats on every visit for as long as the connection stays slow.

That is 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:

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 120KB 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 AbortSignal.timeout implementation.

How the test works, and why a server that never answers cannot tell the implementations apartTop: four steps. Build the site and worker, make a first visit to fill the cache, edit the built HTML to fake a publish, then make a second visit and read what the cache holds. Bottom: two panels with the same 0 to 4 second axis and a deadline at 2500 milliseconds. Left, a server that never answers: both implementations are still waiting for a header when the deadline fires, both abort at the same moment and fall back to cache, so the result is the same. Right, a server that sends the header at once and the body until 4 seconds: after the header arrives, AbortSignal.timeout cuts the body at the deadline while the cancellable timer lets it run to 4 seconds, so the results differ.How the test works1Build the siteand its worker2First visitfills the cache3Edit the built HTMLto fake a publish4Second visit: readwhat the cache holdsWhere the two implementations differServer never answersNo header ever arrives0s4sdeadline 2500msServer answers slowlyHeader at once, body until 4s0s4sdeadline 2500msboth still waiting for a headerAbortSignal.timeoutfalls back to cacheCancellable timerfalls back to cacheSame resultafter the header arrivesAbortSignal.timeoutbody cutCancellable timerDifferent resultsThe two differ only after the header arrives, and a server that never answers never gets there
A server that never answers makes both implementations pass. Only a server that sends the header and then trickles the body reaches the case where the deadline is still running.

A server that never answers cannot distinguish “no answer” from “slow answer”. It never sends headers, so headers arriving inside the budget while the body is still draining after it never happens. 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 it 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 AbortSignal.timeout:

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 pass 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

A test that cannot fail proves nothing, so the same suite also ran against the previous worker:

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

article keeps stale-while-revalidate passes in both runs, and it has to. 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. Replacing AbortSignal.timeout also removed a compatibility question. It arrived in Chrome 103, Firefox 100 and Safari 16, and Chrome only threw the specified TimeoutError from 124, while AbortController predates all three.

Summary

ArticlesListings
StrategyStale-while-revalidateNetwork-first
First visit after a publishCached copy, refreshed behindCurrent copy
Cost of being staleA wording tweak nobody noticesThe article is invisible
OfflineServed from cacheServed from cache
Connection up but hangingServed from cacheCached copy after 2500ms
  • Split a cache by what a URL is for. An index and the content it indexes fail differently 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.
  • A deadline on a fetch outlives the headers unless you cancel it, which turns a guard against slow networks into a guarantee of stale pages.

References

Share this article