Web app update notifications: stale tabs, version skew, and the refresh banner

Desktop apps have an update dialog. Mobile apps have the store. Web apps supposedly have neither problem — you deploy, and everyone is on the new version. Except they aren’t: the version your user is running is whatever was current when their tab loaded, and tabs live a long time. The dashboard someone opened Tuesday morning is still running Tuesday’s JavaScript on Friday. You have shipped three releases since; their tab has received none of them.

That gap is two problems wearing one trenchcoat. The first is mechanical: how does new code get into a tab that’s already open? The second is communication: how do you tell the person in that tab that something changed — and what? Most teams solve neither, which is why the most common web-app update notification in the wild is a crash. This guide covers both halves: detecting staleness, refresh-banner etiquette that never costs anyone their work, and how the humble “refresh to update” banner turns out to be one of the best release-notes surfaces a web app has.

Version skew: why stale tabs break

A stale tab isn’t just behind — it degrades, because the world it was built against keeps moving:

  • Lazy-loaded chunks disappear. Modern bundlers split the app into hashed files (settings-8f3a2c.js). When a user navigates to a route their tab hasn’t visited yet, it requests that chunk — and if your deploy replaced the old files, the request 404s. This is the famous ChunkLoadError, and its user-report signature is unmistakable: “it broke, I refreshed, it fixed itself.” Any bug report shaped like that is version skew, not a bug in the feature.
  • The API moves underneath. The old client keeps calling your API with old assumptions — a field you renamed, a parameter you started requiring, a response shape you changed. Additive API changes are skew-safe; removals and renames are how a deploy silently breaks every open session at once.
  • Real-time protocols drift. If the client and server speak over a websocket, a protocol change strands every connected tab mid-conversation.

Two engineering habits buy you time before any banner is needed. Keep old hashed assets servable for a grace window after each deploy — they’re immutable, so retaining the last several builds’ files costs storage and saves every mid-session user from chunk 404s. And treat the deployed API like a public API for one version’s worth of tolerance: the client population is never all on the latest build, because the client population includes every open tab. But grace windows only defer the question. Eventually the tab has to reload — which means eventually you have to ask.

Detecting a new version from the client

Before you can tell the user anything, the tab has to know it’s stale. In rough order of how boring (good) the options are:

  • Poll a version file. Ship a tiny version.json (build hash + build date) next to your assets, bake the same hash into the bundle at build time, and have the client compare every few minutes — and, crucially, on tab focus. The moment someone returns to a backgrounded tab is exactly when staleness matters and exactly when a check is cheap. A static file behind a CDN scales to any traffic level. This is the boring option. Pick it.
  • Piggyback on API responses. Return the current build in a response header (X-App-Version) on every API call. Zero extra requests; the client learns it’s stale the next time it does anything. Pairs well with the focus check for tabs that are idle.
  • Service worker lifecycle. If you already run a service worker, the browser hands you this: updatefound fires when a new worker installs, and a worker in the waiting state is the update, sitting there ready. Show your banner, and only call skipWaiting() when the user opts in — auto-activating a new worker under open tabs is how you cause the exact mid-session breakage you were trying to prevent. Don’t add a service worker just for update detection; the version file is simpler than the lifecycle you’d be signing up for.
  • Push it. If a websocket is already open, a “new version” event is one more message. Instant, precise — and only worth it because the socket was already there.

Refresh-banner etiquette

Rule zero, above everything else: never reload the page over someone’s unsaved work. A forced refresh that destroys a half-written support reply or a half-configured form is strictly worse than any version skew. Users forgive a stale tab; they do not forgive lost work. Every escalation below is bounded by this rule.

With that fixed, match the intervention to the actual risk:

  • Most deploys: no banner at all. The new version gets adopted on the next natural page load. If old assets stay servable and the API change was additive, a stale tab is harmless — interrupting someone to announce a refactor is pure cost. Silence is the correct default.
  • Worth knowing: a passive banner. A dismissable strip or toast — “A new version is available” — with a Refresh button and nothing blocking. The user finishes what they’re doing and refreshes when ready. Follow the widget rules: never a modal, never auto-opening, not parked next to a destructive button.
  • Skew will bite soon: a persistent banner. When the tab’s build is about to start failing — API tolerance window closing, protocol change deployed — the banner stays visible until acted on, and says why: “Refresh to keep saving your work.” Persistent, still not blocking.
  • Genuine emergency: forced reload, done politely. Reserved for a security fix in the client or a break the old build cannot survive. Even then: preserve state first (stash the draft in local storage and restore it after), warn with a visible countdown, and let “I’m in the middle of something” delay it once. Forced reload is an incident measure, not an update mechanism.

One more budget to respect: banner frequency is not deploy frequency. If you deploy ten times a day — and a healthy continuous-delivery team does — a banner per deploy trains everyone to dismiss banners within a week. Gate the banner on skew risk, not on the existence of a deploy; batch the “worth knowing” tier so it fires at most about daily. The banner is a channel, and channels stay alive by being underused.

The banner is a release-notes surface

Here’s the part most teams throw away. The refresh banner is a moment where the user is actively looking at a message from you about the software changing — the web equivalent of the desktop update dialog, which is the most-read release-notes surface those apps have. A banner that says only “please refresh” spends that attention on a chore. Add one line and one link and it becomes an announcement: the line says the most user-visible thing in the release, and “See what’s new” links to the changelog entry’s permalink. Readers who care click through; readers who don’t still get the refresh.

The moment after the refresh matters just as much. A web app that quietly rearranged itself overnight reads as gaslighting — “where did the export button go?” is a support ticket, a tweet, and a small withdrawal of trust, and the user has no installer or update dialog to explain it. Two fixes: a what’s-new indicator that shows its dot after the reload, gated on an actual version delta so it never cries wolf; and a dated, permalinked changelog entry, so support can answer “that changed in yesterday’s release — here’s the entry” instead of “huh, weird.” Put the build hash somewhere findable (an about panel, a footer) so bug reports can say which version the tab was actually running — with skew in play, that datum saves entire debugging sessions.

Which deploys deserve an announcement

Continuous deployment does not mean continuous announcement. The changelog for a web app tracks user-visible change, not deploy count: ship ten times on Tuesday, and if one of those changed what users see, Tuesday has one entry. Invisible work — refactors, dependency bumps, performance groundwork — batches into occasional summary entries or stays internal. The full framework is in the SaaS changelog guide; the short version is that your deploy log is for you, your changelog is for them.

Versioning follows the same logic. Users of a web app never see or choose a version number, so semver’s compatibility promise has nothing to attach to — the date is the version. Dated entries plus an internal build hash cover everything a web app needs: humans reason about “the Tuesday release,” machines compare hashes.

Copy that works

The whole surface is two sentences and a template:

BANNER (passive tier)
  A new version of Acme is available.
  [Refresh]   See what’s new →

BANNER (persistent tier)
  Refresh to keep saving your work — this version of
  Acme is out of date.
  [Refresh now]   What changed →

CHANGELOG ENTRY (the link target)
  ## Search got faster, and exports learned CSV — 2026-08-01

  Search results now load in under a second on large
  workspaces. You can also export any report as CSV —
  look for the new Export menu on report pages.

  For scripts: the /reports API gained a format=csv
  parameter. Existing calls are unchanged.

Six anti-patterns

  • The silent forced reload. The app reloads itself the moment a deploy lands, taking the half-written form with it. This is the single fastest way to make users afraid of your product. Fix: rule zero — reload only with consent or with state preserved.
  • The modal per deploy. A blocking “New version available!” dialog ten times a day. Attention is a budget; a modal is the most expensive spend, and a routine deploy is the least worthy purchase.
  • “Please refresh” with no what or why. A bare imperative reads as the app’s problem, not the user’s benefit — and gets dismissed. One line about what’s new converts the chore into an announcement.
  • ChunkLoadError as the notification. No detection, no banner — the user finds out about your release when their tab crashes into a missing file. If this page described your app, start with the version file and the asset grace window.
  • The footer version nobody checks. A build number in tiny grey text is an audit trail, not a notification. Fine to have; useless as the only signal that anything changed.
  • The countdown nag that can’t be dismissed. “Reloading in 30 seconds” on every deploy, with no way to say “not now.” The emergency register, spent on routine — when a real emergency comes, the urgency is already worthless.

Where Wakelog fits

Wakelog is the announcement half of this loop. Every post gets a stable permalink — the “See what’s new” link target — plus RSS and a JSON feed your banner code can read to show the latest entry’s title inline. The drop-in what’s-new widget handles the after-refresh moment with a seen-state dot, and the API means your deploy pipeline can publish the entry at deploy time — the same script that updates version.json posts the changelog entry, so the banner and the notes can never drift apart.

Honest caveat: Wakelog doesn’t detect your deploys, poll anything from your users’ tabs, or reload anyone’s page. The version file, the focus check, and the banner are plumbing that lives in your app. What Wakelog gives that plumbing is a destination — the dated, permalinked entry that turns “please refresh” into “here’s what you get when you do.”

Give your refresh banner a link target   Next: in-app what’s-new widgets →

Related guides

Last updated 2026-08-01 · All guides