Blog

A Service Worker Froze Our Prerender on Day One, Forever

One long-lived headless browser, reused for every page. The first render installed a Service Worker, and every render after that — for weeks — quietly inherited it.

stuck cache

The prerender service behind Bezmaske reuses one headless Chrome process across every request — starting a fresh browser per page would be far too slow on a shared server. That decision is correct. What we missed is what else survives between requests when you reuse the same browser: cookies, IndexedDB, and — because the site installs a Service Worker for offline support — a persistent cache the browser will consult before it asks the network anything.

The first page that service ever rendered installed that Service Worker. Every render after it, for any URL, ran inside the same browser profile — which meant the Service Worker was already active, already intercepting, already deciding to answer from its own cache instead of fetching the current build. We shipped fixes. The live site changed. The crawlers kept seeing the exact bundle that existed the moment the service first came online.

Why nothing about the deploy looked wrong

The build succeeded. The deploy script copied the new files. A disk-level render cache we already maintained was invalidated correctly on every deploy. Every layer we normally check was green. The staleness lived one level further down, in browser state that our own tooling had no visibility into and no reason to suspect, because we had never needed to think about a Service Worker as something a server-side render step could inherit.

// Before: every render shares one profile, and whatever the
// first render installed — cookies, cache, a Service Worker —
// is still active for every render that follows.
const page = await browser.newPage();

// After: a fresh, throwaway profile per render. Nothing carries
// over, which is exactly what a "give me the current page" call
// should guarantee.
const context = await browser.createIncognitoBrowserContext();
const page = await context.newPage();
// ...
await context.close();

What actually surfaced it

Not monitoring — a manual spot check during an unrelated fix, comparing what a crawler-flavoured request returned against what the live site actually served. The HTML referenced a JavaScript bundle that had not existed for days. Once we knew what to look for, confirming it took minutes: the same stale bundle came back for every page, in every request, until the process was restarted.

A render pipeline that reuses a browser for speed is also, without asking, reusing whatever that browser remembers — and a Service Worker exists specifically to remember on purpose.

The fix costs a small amount of overhead per render — an incognito context is not free — which is a trade we take gladly over a crawler seeing a version of the site that stopped being true days or weeks earlier.

This came out of building Bezmaske Read the case study

Have something like this to fix?

Describe the problem and we will tell you what it takes.

Get in touchAll posts