00.00.00

Blog

Blogs/your-users-are-stuck-on-yesterdays-build

WebDeployUX

Your Users Are Stuck on Yesterday’s Build

Long-lived tabs keep old JS around after deploys. Detect a new build, tell the user, and let them refresh on their terms.

Oct 24, 20266 min—views

Modern web apps don’t feel like “pages” anymore. People leave them open for days — a todo list in one tab, a dashboard in another — switching between them without ever doing a hard refresh.

That’s great for flow. It’s terrible for deploys.

The quiet problem

Browsers are aggressive about keeping things fast:

  • Service workers and Cache Storage keep an old shell around.
  • Soft navigations and tab switches reuse the already-loaded app.
  • Back/forward cache can revive a tab that never truly left.

So you ship a fix. The server has the new version. Your user still has last week’s JavaScript in memory (or on disk). Nothing looks broken — until a feature is missing, a bug they reported is “still there,” or two teammates somehow see different UIs.

They didn’t refuse to update. The product never asked them to.

What good products do instead

Don’t yank the tab out from under people. Detect that a newer build exists, then offer a clear, optional refresh — inform, don’t ambush.

  • Todoist — a small badge that a new version is ready
  • Slack — a prompt to reload when a newer client is available
  • Discord — “New update available” with a refresh path
  • Notion / Linear-style apps — quiet “update available” affordances

A stack-agnostic approach

Three pieces cover almost any SPA or PWA.

1. A build id that changes every deploy — bake a unique string into the client at compile time (commit SHA, CI build number, or timestamp).

2. A never-cached version check:

httpGET /api/version
GET /api/version
→ { "buildId": "abc123" }
Cache-Control: no-store

On the client, compare that remote id to the id baked into the current page. Poll every minute or so, and again when the tab becomes visible. If they differ → show the prompt.

tslib/check-version.ts
const LOCAL_BUILD_ID = process.env.NEXT_PUBLIC_BUILD_ID!;

export async function checkForUpdate() {
  const res = await fetch("/api/version", { cache: "no-store" });
  const { buildId } = await res.json();
  return buildId !== LOCAL_BUILD_ID;
}

3. A refresh action that actually updates — clear Cache Storage if present, tell a waiting service worker to activate, then reload:

tslib/force-refresh.ts
export async function forceRefresh() {
  if ("caches" in window) {
    const keys = await caches.keys();
    await Promise.all(keys.map((key) => caches.delete(key)));
  }

  const registration = await navigator.serviceWorker?.getRegistration();
  registration?.waiting?.postMessage({ type: "SKIP_WAITING" });

  window.location.reload();
}

What not to do

  • Auto-reload without asking (destroys drafts and trust).
  • Cache the version endpoint (you’ll never see updates).
  • Reuse the same build id across deploys (the check becomes useless).
  • Show the banner in local development noise.
Detect a new deploy. Tell the human. Let them reload on their terms.