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.

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:
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.
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 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 |
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.
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:
- The probe is the same round trip that network-first waits for, so on a healthy connection it saves almost nothing.
- 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.
- 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:
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.
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:
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.
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 persistAgainst the cancellable timer:
PASS a slow body still lands in the cache served fresh; cache updatedVerification
Eight checks pass 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 itA 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 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 fallbackarticle 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
| 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 |
- 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
fetchoutlives the headers unless you cancel it, which turns a guard against slow networks into a guarantee of stale pages.
References
- Fetch Standard: abort a fetch() call, which errors the response body even after the promise has fulfilled
- Service Workers specification: Handle Fetch, which starts the update check after the fetch event has run
- MDN on the service worker lifecycle and when the browser checks for an update
- MDN on AbortSignal.timeout()
- Can I use: AbortSignal.timeout() browser support


