npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@astrapi69/pwa-update

v0.2.0

Published

Framework-agnostic PWA update detection: version.json manifest comparison, service-worker activation with capped-backoff retries, accept/dismiss suppression, and encoded platform quirks (iOS standalone full-restart, CDN edge-cache window)

Readme

@astrapi69/pwa-update

Framework-agnostic PWA update detection: compare the running build against a deployed version.json, drive the service-worker activation, and never nag a user who already pressed Update.

Zero dependencies. No build-tool coupling — the running build, the manifest URL and the storage namespace are all parameters.

npm install @astrapi69/pwa-update

React UI: @astrapi69/pwa-update-react. Vite build half: @astrapi69/vite-plugin-build-version.

Quick start

import { createUpdateStore } from "@astrapi69/pwa-update";

const store = createUpdateStore({
    build: { version: __APP_VERSION__, buildHash: __BUILD_HASH__ },
    manifestUrl: `${import.meta.env.BASE_URL}version.json`,
    storageNamespace: "my-app",
});

store.ensureInit(navigator.onLine);              // passive detection
store.subscribe(() => render(store.getSnapshot()));

// explicit check (a settings button)
await store.checkNow();

// user pressed Update
store.apply();

Your build must emit the matching manifest:

{ "version": "2.4.0", "buildHash": "a1b2c3d", "buildDate": "2026-07-20T10:00:00Z" }

Keep it OUT of your service-worker precache globs so it is always fetched fresh rather than served from a stale precache.

Three things a host usually needs

Unsaved work: flush before the reload (onBeforeApply)

An app holding unsaved state — an editor buffer, a draft — must write it out before the page reloads. Doing that in beforeunload is best-effort only: the event cannot await an async IndexedDB write. Pass a hook instead; it is awaited before the activation starts.

createUpdateStore({
    build, manifestUrl,
    onBeforeApply: () => flushEditorToIndexedDb(),   // awaited
});

A rejection is swallowed: a failed flush must never strand the user on a stale build with a dead Update button.

No deployed manifest: SW-only mode (manifestUrl: null)

Not every deployment can serve a static version.json. Pass null and detection rests entirely on the service-worker cycle — a quiet cycle then reports current, never error, because there is nothing to fetch.

createUpdateStore({ build, manifestUrl: null, storageNamespace: "my-app" });

Long-lived tabs: proactive polling (polling)

The baseline (start + foreground return) suits an app the user opens and closes. An app someone keeps open for hours would never notice a deploy, so repeated checks are opt-in:

createUpdateStore({
    build, manifestUrl,
    polling: { intervalMs: 60 * 60 * 1000, onFocus: true },
});

const stop = store.startPolling(() => navigator.onLine);   // React: automatic

Ticks route through the same throttle as the foreground re-check, so a value below foregroundRecheckThrottleMs simply polls at the throttle rate.

Platform quirks this package encodes

This is the part you cannot get from a generic version-compare snippet. Each item below cost a production incident to learn.

1. An installed iOS PWA needs a full app restart

On iOS/WKWebView in standalone display mode, a freshly installed service worker frequently does not take control on skipWaiting() + reload — the way it does on every other platform. It activates reliably only after the app is fully closed and reopened.

An update UI that assumes the reload is enough leaves iOS users pressing a button that visibly does nothing. So "needs a full restart" is a named, first-class property here, not an internal detail:

createUpdateStore({
    build,
    manifestUrl,
    quirks: {
        // default: detectIosStandalone
        needsFullRestart: () => myOwnPredicate(),
    },
});

store.getSnapshot().needsFullRestart; // -> show "close the app and reopen it"

It is a predicate, not a boolean flag, so what is actually being decided stays visible — and it is part of the state, so a UI cannot silently drop the hint during a refactor.

2. A backgrounded PWA stops polling — re-check on foreground

The same iOS suspension means the manifest poll and the worker both freeze while the app is in the background. Returning to the foreground is the only reliable moment to re-detect a new build:

document.addEventListener("visibilitychange", () => {
    if (document.visibilityState === "visible") store.maybeRecheck(navigator.onLine);
});

maybeRecheck throttles itself (default 15 min). Pick a value at or above your host's version.json cache TTL — checking more often than the edge cache refreshes yields no new signal. GitHub Pages serves max-age=600.

3. The CDN keeps serving the old sw.js for a while

Right after a deploy the manifest can already report a newer build while the edge still hands out the previous sw.js — so the worker cycle produces no waiting worker. Reporting "up to date" there is a lie; offering an apply button is a dead control.

checkNow() reports preparing for exactly this window. Surface it as "a new build is being prepared, check again shortly".

Related: manifest fetches carry both cache: "no-store" and a cache-buster query param. no-store bypasses the browser cache and the service worker but not a CDN edge cache.

4. The build hash is the truth, not the version string

On a rolling channel (a "latest" preview deploy) the version string never changes between deploys — only the hash moves. Suppression keyed on the version alone mutes the update banner forever after one accepted update. AcceptanceGuard records version and hash, so a same-version deploy with a newer hash re-offers the update once the quiet window passes.

5. Never reload onto a stale build

activateInBackground() retries the skip-waiting handshake on a capped backoff and reloads only when a fresh worker actually takes control. If it never takes within the budget it gives up silently — no reload, no banner. A forced reload onto the old precache would just make the banner reappear, and the user would press Update again, forever.

6. Accept and dismiss are different intents

  • Dismiss ("Later") — re-offered on the next app start.
  • Accept ("Update") — suppressed for a quiet window and for the exact build accepted, across reloads, backed by three redundant layers (session flag, timestamp, accepted build). A stale reload cannot re-nag.

7. A stale deploy also breaks lazy routes

Old hashed chunks are purged while a stale index.html still references them, so navigating to a not-yet-loaded route throws "Failed to fetch dynamically imported module". isChunkLoadError / shouldReloadForChunkError recognise that family; the React package ships the lazyWithReload wrapper.

API

| Export | Purpose | |---|---| | createUpdateStore(options) | The store: passive detection, explicit check, apply/dismiss, banner visibility | | store.startPolling(isOnline) | Start the configured interval / focus polling; returns a stop function | | checkForUpdateReliable(deps) | One-pass check: manifest and worker cycle, awaited together | | activateInBackground(options) | Capped-backoff activation that never reloads onto a stale build | | activateAndReload(options) | Activation with a safety-net reload | | awaitServiceWorkerUpdate(...) | Await the worker install cycle with a timeout | | AcceptanceGuard | Accept-suppression rules, reusable for a custom surface | | isUpdateAvailable, parseVersionManifest, fetchLatestVersion, knownBuildHash | Pure manifest helpers | | detectIosStandalone, isIosDevice, isIosStandalone, isStandaloneDisplay | Platform detection | | isChunkLoadError, shouldReloadForChunkError | Stale-deploy chunk failures | | NamespacedStore, defaultLocalStore, defaultSessionStore | Namespaced, never-throwing storage |

Storage

Three small facts are persisted (accepted build, last check time), all device-local UI state. Keys are namespaced; every access is guarded, so Safari private mode degrades to "no persistence" instead of throwing. Inject your own stores for tests or SSR:

createUpdateStore({ build, manifestUrl, storage: { local, session } });

License

MIT © Asterios Raptis