Skip to content

fix(pwa): Reload stale clients when an updated worker takes control #171

Description

@OffCrazyFreak

Description

A client that remains open across a production update can keep the previous Next.js webpack runtime in memory while a newly activated service worker immediately takes control of that page. The next App Router navigation can then combine the old client runtime with a React Server Component response from the new deployment and crash in webpack with Cannot read properties of undefined (reading 'call').

This was observed once in production as DISSCOUNT-FRONTEND-F. The product page is where the incompatible navigation happened, but the evidence does not point to product domain logic.

Production evidence

  • Event 807fe7821c7a4d90b9513548b06fec67 occurred on 2026-08-13 at 05:04:35 UTC in Chrome 150 on Windows.
  • Sentry recorded one occurrence and no recurrence in the preceding 90-day search.
  • The replay breadcrumbs span activity from at least 2026-08-12 at 05:08 UTC through the failure. Product and price requests repeatedly completed with HTTP 200, so the page had been functioning before the crash.
  • Immediately before the error, the user submitted a search and the App Router fetched /products?q=%C5%A1e%C4%87er&_rsc=... with HTTP 200.
  • The stack uses webpack runtime webpack-b54c8363719dcd3f.js and route chunk app/products/page-b3222101cbf5cd19.js.
  • Current production HTML uses webpack-9b085f7ea89462a4.js. The old b54c... runtime now returns HTTP 404.
  • The page-b322... route chunk is still current. Its Last-Modified is 2026-08-07 14:09 UTC, while the current runtime and sw.js were replaced at 19:42 UTC after the later production release. This is consistent with an unchanged route chunk surviving across builds while the build-scoped runtime changed.
  • In the current runtime, the failing line is webpack's module loader: modules[moduleId].call(...). The exception means the old runtime was asked for a module ID that does not exist in its module table.

The exact service-worker lifecycle event was not captured, so the final link is an inference. It is strongly supported by the asset hashes, deployment timestamps, long-lived client, and the current lifecycle configuration.

Root cause

The worker opts into immediate replacement with skipWaiting: true and clientsClaim: true. A new worker can therefore take over a page that still has JavaScript from the previous deployment without reloading that page.

@serwist/next currently registers the worker automatically. reloadOnOnline only adds a reload when the browser returns online. It does not reload a page when an updated worker becomes its controller. There is no controlling or controllerchange handler elsewhere in frontend/src.

Serwist documents this exact risk: long-lived tabs can run site and worker versions simultaneously, and its controlling event identifies when a new worker takes control. See The controlling event and When an unexpected version of the service worker is found.

Likely sequence:

  1. Build A loads and keeps runtime b54c... in the page.
  2. The browser discovers the worker from build B. skipWaiting activates it immediately and clientsClaim moves the existing page under its control.
  3. The page is not reloaded, so its build A webpack runtime remains in memory.
  4. A client navigation requests a fresh build B RSC payload.
  5. Build A's webpack runtime resolves a build B module ID. The module factory is absent, so webpack throws while reading .call.

Proposed fix

Add a small client-side PWA update coordinator that reloads exactly once when an updated or external Serwist worker starts controlling an already loaded page.

The safest registration order is to disable automatic registration, attach the controlling listener to window.serwist, then call register(). Ignore initial installation, and reload only when the event represents an update or unexpected external controller. Add a per-navigation or sessionStorage guard so a bad lifecycle edge cannot create a reload loop.

Keep the worker's offline caches and the React Query IndexedDB store intact. A version-alignment reload must not call the identity cache purge or discard queued offline writes.

Done when

  • First-time service-worker installation does not reload the page.
  • When an updated worker takes control of an existing page, that page reloads exactly once onto the current build.
  • An unexpected external controller is handled without a reload loop.
  • Navigation after an update cannot mix the previous webpack runtime with the current RSC payload.
  • Offline caches, persisted queries, and queued offline mutations survive the version-alignment reload.
  • The update decision is covered by a focused test where practical.
  • Prettier, tsc --noEmit, and lint pass, and the production build output confirms the intended registration order.

Scope and gotchas

  • Start in frontend/next.config.ts, frontend/src/app/sw.ts, and a focused client component under frontend/src/components/custom/pwa/ mounted from frontend/src/app/layout.tsx.
  • Do not solve this by filtering the Sentry exception. The crash represents a genuinely broken navigation.
  • Do not fold DISSCOUNT-FRONTEND-E into this work. That was a separate one-off Safari failure to load sw.js and did not show a mixed-build runtime.
  • Service workers are disabled in development, so lifecycle verification must use production build artifacts or an isolated test. Do not start another dev server.

Affected area

Frontend (web / PWA)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions