
A field tech messaged me from a basement with no signal: “Your app just hangs. White screen. Forever.”
That’s a fun one to read on a Tuesday, because the entire point of that app was that it worked offline. Progressive Web App. Service worker. Cached shell. The whole pitch was “open it anywhere, signal or not.”
So I did what he did. I put my phone in airplane mode and tapped the icon.
Blank screen. Spinner that never spun. The app didn’t crash — it just waited. Patiently. Forever. Like a golden retriever staring at a door that’s never going to open.
The investigation
On a normal connection, everything was perfect. Load times snappy, cache populated, Lighthouse happy. The bug only showed up with the network truly gone — not slow, gone.
That asymmetry is the tell. If something works online and dies offline, you stop blaming the app and start blaming whatever sits between the app and the cache. For a PWA, that’s the service worker’s fetch handler.
I pulled it up. Here’s the offending strategy, paraphrased:
// service-worker.js — the original sin
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request) // always try the network first
.then((res) => {
const copy = res.clone();
caches.open('app-shell').then((c) => c.put(event.request, copy));
return res;
})
.catch(() => caches.match(event.request)) // fall back to cache
);
});
Network-first. Looks reasonable. Hit the network, cache the response, and if the network fails, serve from cache. The .catch() is right there. So why doesn’t it fall back?
The “aha”
Because offline doesn’t fail. Not quickly, anyway.
When you’re truly offline, that fetch() doesn’t reject in 20 milliseconds with a clean “no route to host.” Depending on the platform, it can sit there for tens of seconds — or effectively never resolve — before the OS decides it’s done. The .catch() is waiting for a rejection that takes its sweet time. Meanwhile, the app shell never paints, because the navigation request for index.html is stuck in that same limbo.
The cache was fine. The fallback logic was fine. The problem was that the fallback never got to run, because nothing told the network attempt to give up.
ONLINE OFFLINE (the bug)
┌──────────────┐ ┌──────────────────────┐
│ fetch() │ │ fetch() │
│ │ │ │ │ │
│ ▼ ~80ms │ │ ▼ │
│ ✓ response │ │ ...waiting... │
│ │ │ │ ...waiting... │
│ ▼ │ │ ...waiting... ▓ │
│ cache.put │ │ (never rejects) ▓ │
│ │ │ │ │ ▓ │
│ ▼ │ │ ✗ .catch() │
│ paint ✓ │ │ never fires │
└──────────────┘ └──────────────────────┘
app shell never paints
The fix
Two changes. First, never let a fetch in the service worker run unbounded — race it against a timeout so it fails fast and the cache fallback actually fires:
function fetchWithTimeout(request, ms = 3000) {
return Promise.race([
fetch(request),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('sw-fetch-timeout')), ms)
),
]);
}
Second, stop being network-first for the app shell. Serve from cache immediately, then refresh the cache in the background — classic stale-while-revalidate:
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.open('app-shell').then(async (cache) => {
const cached = await cache.match(event.request);
const network = fetchWithTimeout(event.request)
.then((res) => {
cache.put(event.request, res.clone()); // refresh in background
return res;
})
.catch(() => cached); // offline or slow? cached is already in hand
return cached || network; // paint instantly if we have it
})
);
});
Now the shell paints from cache on the first frame, online or off. The network update happens behind the scenes, and if it’s slow or absent, the timeout cuts it loose in three seconds instead of never.
Airplane mode, tap the icon, instant load. The basement tech got his app back.
Why it happened
Network-first feels safe — “always get the freshest thing, fall back to cache if it breaks.” But it quietly assumes the network fails promptly. Offline doesn’t fail promptly. It stalls. And a stalled promise with no timeout is just an infinite loading screen wearing a .catch() as a disguise.
The app shell — the HTML, JS, CSS that make the thing exist — should never depend on a live connection to render. That’s the entire premise of “offline-capable.” We’d built the cache and then put a blocking network call in front of it.
Takeaways
- Never let a service-worker
fetchrun without a timeout.Promise.raceit against asetTimeoutreject so offline fails in milliseconds, not minutes. - Serve the app shell cache-first or stale-while-revalidate — never network-first. The UI should paint from cache on the first frame, every time.
- Test with the network truly gone, not just throttled. Slow networks reject; dead networks stall. They’re different failure modes and only the dead one exposes this bug.
- A
.catch()is not a fallback if nothing triggers the rejection. Error handling only runs when the error actually fires — and “offline” often doesn’t fire fast. - “Offline-capable” is a claim you have to test in airplane mode, not a checkbox you tick because you registered a service worker.