Network-First with a Timeout for Navigations
After reading this you will be able to bound how long a navigation waits for the network before falling back to a cached response — the strategy that fixes the “lie-fi” case where the connection is technically alive but effectively useless.
← Back to Service Worker Routing Strategies
Prerequisites
Core Concept
A plain network-first strategy asks the network and falls back to the cache only when the request fails. That covers being fully offline, where the fetch rejects quickly. It does not cover the far more common and far more annoying case: a connection that accepts the request and then delivers nothing for thirty seconds. There is no error to catch, so the fallback never runs and the user stares at a blank tab.
Adding a timeout turns “wait until the network gives up” into “wait a fixed budget, then serve what we have”. Promise.race between the fetch and a timer expresses it in a few lines. The timer does not cancel the request — that would throw away work that may still complete — so the fetch continues in the background and its result updates the cache for next time. The user gets a fast, slightly stale page now, and a fresh one on their next visit.
The budget is a product decision, not a technical one. Too short and users on genuinely slow but working connections always get stale content; too long and the fallback never fires when it matters. A useful anchor: whatever your target for Largest Contentful Paint is, the timeout should be comfortably below the point at which a user would have given up — in practice 2.5 to 4 seconds for content, shorter for an app shell that is nearly as good as the live page.
One refinement makes the strategy noticeably better: adapt the budget to the connection. The Network Information API reports an effective connection type, and giving a 2g connection a longer budget avoids serving stale content to someone whose network is slow but perfectly functional.
Implementation
// TypeScript 5.x — sw.ts
/// <reference lib="webworker" />
declare const self: ServiceWorkerGlobalScope;
const RUNTIME_CACHE = "pages-v1";
const SHELL_URL = "/app-shell/";
/** Budget in milliseconds, adapted to the reported connection quality. */
function navigationBudget(): number {
const connection = (self.navigator as { connection?: { effectiveType?: string } }).connection;
switch (connection?.effectiveType) {
case "slow-2g": return 8000; // slow but working — do not punish it with stale content
case "2g": return 6000;
case "3g": return 4000;
default: return 3000;
}
}
The race itself. The timer resolves rather than rejects, so the two branches are distinguishable without exception handling:
// TypeScript 5.x
const TIMED_OUT = Symbol("timed-out");
async function networkWithBudget(
request: Request,
ms: number,
): Promise<Response | typeof TIMED_OUT> {
let timer: number | undefined;
const timeout = new Promise<typeof TIMED_OUT>((resolve) => {
timer = self.setTimeout(() => resolve(TIMED_OUT), ms);
});
try {
return await Promise.race([fetch(request), timeout]);
} finally {
// Always clear, or a worker termination is delayed by the pending timer.
self.clearTimeout(timer);
}
}
The handler ties it together. The key decision is that the losing fetch is not aborted — it keeps running and refreshes the cache:
// TypeScript 5.x
async function navigationHandler(event: FetchEvent): Promise<Response> {
const cache = await caches.open(RUNTIME_CACHE);
// Start the network attempt and keep a handle on it for background caching.
const networkAttempt = fetch(event.request)
.then(async (response) => {
if (response.ok) await cache.put(event.request, response.clone());
return response;
});
// Race a copy of the promise against the budget.
const budget = navigationBudget();
const raced = await Promise.race([
networkAttempt.catch(() => TIMED_OUT as typeof TIMED_OUT),
new Promise<typeof TIMED_OUT>((r) => self.setTimeout(() => r(TIMED_OUT), budget)),
]);
if (raced !== TIMED_OUT) return raced;
// Budget expired (or the network failed): serve what we have.
const cached = (await cache.match(event.request)) ?? (await caches.match(SHELL_URL));
// Let the request finish in the background so the cache is warm next time.
event.waitUntil(networkAttempt.catch(() => undefined));
return (
cached ??
new Response("This page is not available offline.", {
status: 503,
headers: { "Content-Type": "text/plain; charset=utf-8" },
})
);
}
self.addEventListener("fetch", (event) => {
if (event.request.mode !== "navigate") return;
event.respondWith(navigationHandler(event));
});
Serving a stale page silently is a small betrayal of the user’s trust, so tell them:
// TypeScript 5.x — page-side, so the UI can show a "showing cached version" bar
navigator.serviceWorker.addEventListener("message", (event) => {
if (event.data?.type === "served-from-cache") {
document.querySelector("[data-stale-banner]")?.removeAttribute("hidden");
}
});
Verification
The default DevTools presets do not reproduce the case this strategy exists for. Create a custom profile with a very high latency and normal throughput — that is “lie-fi”, and it is where a plain network-first strategy hangs.
// @playwright/test v1.44
import { test, expect } from "@playwright/test";
test("a stalled navigation falls back within the budget", async ({ page }) => {
await page.goto("/");
await page.evaluate(() => navigator.serviceWorker.ready);
// Accept the request, then never respond — the failure mode a rejection test misses.
await page.route("**/reports", () => new Promise(() => {}));
const started = Date.now();
await page.getByRole("link", { name: "Reports" }).click();
await expect(page.locator("[data-app-shell]")).toBeVisible({ timeout: 5000 });
const elapsed = Date.now() - started;
expect(elapsed).toBeGreaterThan(2400); // it did wait for the network
expect(elapsed).toBeLessThan(4500); // but not indefinitely
});
test("a fast navigation is served fresh, not from cache", async ({ page }) => {
await page.goto("/");
await page.evaluate(() => navigator.serviceWorker.ready);
await page.getByRole("link", { name: "Reports" }).click();
await expect(page.locator("[data-stale-banner]")).toBeHidden();
});
Gotchas
- Do not abort the losing fetch. It may still complete, and its response is exactly what should be in the cache for the next visit. Aborting throws that away for no benefit.
- Always clear the timer. A pending
setTimeoutkeeps the service worker alive longer than necessary and delays termination. event.waitUntilis required for the background write. Without it the worker may be terminated before the cache is updated, so the fallback never gets fresher.- A cached response with no cached shell means a blank fallback. Precache the shell during install so the last resort always exists.
- The Network Information API is not universal. Feature-detect and fall back to a fixed budget rather than assuming
effectiveTypeis present. - Tell the user when content is stale. A silent stale page is indistinguishable from a broken one when the data is visibly out of date.
- Test with latency, not with offline. Going offline exercises the rejection path, which already worked; the timeout path needs a stalled connection.
FAQ
How is this different from stale-while-revalidate? Stale-while-revalidate serves the cache first, always, and updates in the background — fastest, but never fresh on the first paint. This strategy prefers fresh content and only falls back when the network is too slow to be useful.
Should the same budget apply to API requests? Usually a shorter one. A navigation has a good fallback (the shell); an API call often does not, so failing fast and showing a retry is frequently better than waiting.
What if the cached page is very old? Store a timestamp alongside it and decide a maximum age. Beyond that, showing an explicit offline page is more honest than a year-old article presented as current.
Does this replace an offline fallback page? No, it uses one. The offline fallback page is the last resort when neither the network nor a cached copy of that specific URL is available.
Why race instead of AbortSignal.timeout? Because aborting cancels the request. AbortSignal.timeout is the right tool when you genuinely want to stop the work; here you want to stop waiting for it.
Related
- Service Worker Routing Strategies — the full set of strategies and how to pick between them per route.
- Cache-First vs Network-First for Navigation — the underlying choice this strategy refines.
- Navigation Preload & Streaming — reducing the latency that makes a timeout necessary in the first place.