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.

Three network conditions and how the timeout strategy handles each Three cases. On a fast network the response arrives well inside the budget and is served and cached. On a stalled connection the timer wins and the cached shell is served while the request continues in the background. When fully offline the fetch rejects immediately and the cache is served at once. budget: 3000ms fast network network 420ms served fresh · cache updated stalled connection request still open… cached shell served fetch continues not aborted — its result still warms the cache for next time fully offline reject cached shell served the timer never fires — the error path is faster
The timeout only matters in the middle case, which is also the one a plain network-first strategy handles worst.

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");
  }
});
Choosing a navigation budget A scale of budget values from one second to eight seconds, annotated with the tradeoff at each end: too short serves stale content to working connections, too long means the fallback rarely fires. A recommended band of two and a half to four seconds is highlighted, with longer budgets for slow effective connection types. Where to set the budget recommended band: 2.5s to 4s 1s 8s too short working-but-slow connections always get stale content too long the fallback never fires when the user needs it adapt by effectiveType 4g → 3s · 3g → 4s 2g → 6s · slow-2g → 8s A fixed budget is a compromise; adapting it removes most of the compromise.
The budget trades freshness against responsiveness; adapting it to the reported connection type reclaims most of what a fixed value gives up.

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 setTimeout keeps the service worker alive longer than necessary and delays termination.
  • event.waitUntil is 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 effectiveType is 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.
Adapting the navigation budget to the connection Five connection conditions with the timeout budget each should get, from three seconds on a fast link up to eight seconds on the slowest, plus a data-saver case that serves the cache immediately. Budget by effective connection type budget reasoning 4g or unknown 3000ms a working link answers well inside this 3g 4000ms slower but genuinely functional 2g 6000ms stale content is worse than waiting here slow-2g 8000ms almost anything beats an offline page saveData enabled serve cache first the user asked you not to spend data
A fixed budget punishes slow-but-working connections; reading the effective type costs one property access.

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.