offline
Everything a SPA needs to work fully offline — split into the two places the work actually happens.
At build time, the
offline-postbuild CLI reads offline.json,
collects your build's files, generates a service worker
(sw-min.js) with a precache manifest
(critical-assets.json) and injects a small bootstrap
script into index.html. At runtime,
the OfflineTracker class registers that worker, warms
the cache and answers one question precisely:
is this app ready to work offline? — as a boolean, a
subscription and a promise. No rxjs, no signals — plain TypeScript
and promises.
Getting started
Install#
npm i @dmytromykhailiuk/offline @dmytromykhailiuk/network-connection
OfflineTracker consumes
@dmytromykhailiuk/network-connection for every
online/offline decision and
never initializes it on its own — the app owns
that singleton. OfflineTracker.init() throws
immediately when NetworkConnection.init() has not
been called first.
Quick start#
Three pieces: a config file next to your project, one script in the build pipeline, a few lines at app startup.
1. Describe the offline layer in
offline.json:
{
"name": "Client",
"buildPath": "/dist",
"themeColor": "#1c1c1c",
"icons": [
{ "src": "icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" }
],
"criticalAssets": ["**.js", "**.css", "**.svg", "/images/logo.png"],
"lazyLoadAssets": ["/images/*.jpg"],
"dataGroups": [
{ "name": "translations", "urls": ["/translations/"], "maxSize": 1 }
]
}
2. Run the CLI after every build:
{
"scripts": {
"postbuild": "offline-postbuild"
}
}
3. Wire the runtime at startup — here with a blocking screen while the connection is down and the app is not yet ready to work offline:
import { NetworkConnection } from "@dmytromykhailiuk/network-connection";
import { OfflineTracker } from "@dmytromykhailiuk/offline";
await NetworkConnection.init("/health.txt");
OfflineTracker.init({ disabled: import.meta.env.DEV });
OfflineTracker.registerServiceWorker();
const networkUnsubscribeFn = NetworkConnection.subscribe((isOnline) => {
if (!isOnline && !OfflineTracker.isOfflineReady && !NetworkBlockingScreen.isVisible()) {
NetworkBlockingScreen.show();
}
if (isOnline && NetworkBlockingScreen.isVisible()) {
NetworkBlockingScreen.hide();
}
});
OfflineTracker.whenOfflineReady().then(() => networkUnsubscribeFn());
init() only kicks the process off. A repeat visit —
online or offline — initializes from the cached
critical-assets.json (a client-side cache-first
bucket via @dmytromykhailiuk/cache-request,
invalidated by the bootstrap's new-build cache wipe); only on a
first-ever offline visit is there nothing to fall back on, so the
cache warms up once the connection appears. Follow the progress
through subscribe(),
whenOfflineReady() and isOfflineReady.
The worker and the manifest exist only after
offline-postbuild runs over a real build. In dev,
initialize with disabled: true — the tracker stays
readable but inert: no requests, no listeners, no registration,
and isOfflineReady stays false.
How it works#
-
The CLI walks the build directory, matches files
against the
criticalAssets/lazyLoadAssetspatterns and writes the resulting pathnames — prefixed withdeploymentPath— intocritical-assets.jsonand into the generated worker. It also stampsbuild-timestamp.txtand injects the bootstrap script intoindex.html. -
The worker serves SPA routes network-first from a
dedicated index cache, assets cache-first, and
dataGroupsURLs cache-first into named caches. It also understands a diagnostic header: a request carryingX-Cache-Only: trueis answered from the cache or failed — never sent to the network. - The tracker asks the worker "which of these are cached yet?" — in one message for a long list, asset by asset with that header otherwise. Whatever is missing gets fetched (which makes the worker cache it), re-checked on an interval, paused while offline, resumed on reconnect — until everything critical is confirmed. Lazy assets do the same in the background.
Build time
offline.json#
Lives wherever you point --config at (default: the
directory you run the CLI from). Every path is resolved relative to
the config file's directory — a leading / is a
convention, not a filesystem root.
Every field#
The built app's directory, e.g. "/dist".
Everything the CLI generates lands here.
The app's name, shown at install time — name of
the generated manifest.
short_name of the manifest — the homescreen
label.
theme_color of the manifest.
background_color of the manifest.
How the installed app is displayed —
"standalone" is its own window without browser UI
(the PWA default), "fullscreen" hides even the
status bar, "minimal-ui" keeps a minimal set of
navigation controls, "browser" opens as a regular
tab. Anything else fails validation.
Manifest icons, passed through verbatim. src is
resolved by the browser relative to the manifest file —
deployment-agnostic relative paths like
"icons/icon-192x192.png" just work.
The path prefix the app is deployed under, e.g.
"/client" when the app is served from
https://host/client/. It is baked into every
generated pathname, into the worker's route matching and into
the window.__OFFLINE_CONFIG__ global the runtime
reads. Leave it out for root deployments.
The built HTML entry inside buildPath.
Pathname prefixes of your SPA routes. Requests under any of
them (plus the deployment root and the index itself) are
served with the cached index.html, network-first.
An empty prefix would turn every request into a SPA
route — hence the non-empty default.
The request header the worker forwards when fetching on behalf of the page — set it when your assets sit behind a custom auth header.
Patterns of files the app cannot work offline without —
Angular-PWA-style globs. ** matches across path
segments ("**.js" — every JS file at any depth),
* within one segment
("/images/*.png", "*-test-*/**.js"),
? exactly one character
("/icon-??.png"), and a ! prefix
excludes — a file must match at least one plain pattern and no
! pattern (["**.js", "!**.spec.js"]). Anything else is an exact path. The matched files become
critical-assets.json — the list
OfflineTracker verifies.
Same pattern language, lower urgency: cached by the worker and loaded by the tracker in the background, without blocking critical readiness. A file matched by both lists counts as critical.
Named runtime caches for data the build does not contain —
themes, fonts, translations, models. Matched by
urls prefixes and/or patterns
globs. E.g.
[{ "name": "translations", "urls": ["/translations/"],
"maxSize": 1 }]
serves everything under /translations/
cache-first from its own capped cache. Every field —
dataGroups.
dataGroups#
Named runtime caches for data the build does not contain — themes,
fonts, translations, models. A request belongs to a group — and is
served cache-first from data-group-<name> — when
its pathname starts with any of the group's
urls prefixes or matches
any of its patterns.
Cache name suffix; must be unique across groups.
Pathname prefixes, e.g. ["/translations/"]. The
CLI prefixes them with deploymentPath at
generation time, so write them deployment-agnostic.
Glob patterns matched against the request pathname — the
same language as
criticalAssets: ** across segments, * within one,
? a single character, anything else an exact
path. Compiled at build time (by the same matcher) and, like
urls, made deployment-aware automatically — write
them deployment-agnostic. A group may combine
urls and patterns; either list may
be omitted as long as the other is non-empty.
Cap on entries in the group's cache; the oldest entry is
evicted first. Useful for versioned resources where only the
current one matters — maxSize: 1 keeps exactly
the latest.
"dataGroups": [
{ "name": "translations", "urls": ["/translations/"], "maxSize": 1 },
{ "name": "images", "patterns": ["**.webp", "**.avif"] },
{ "name": "themes", "urls": ["/themes/"], "patterns": ["**/skins/**.css"] }
]
The generated manifest#
Every run generates manifest.webmanifest in the build
directory — built from the flat fields above (name,
shortName, themeColor,
backgroundColor, display,
icons) — and adds
<link rel="manifest"> to the injected head block,
making the app installable with zero extra steps. camelCase config
keys map to the spec's snake_case on output.
Both derive from deploymentPath —
"/client" becomes
"scope": "/client/" and
"start_url": "/client/" (root deployments get
"/"). One source of truth for the worker, the
tracker, the bootstrap and the manifest.
// offline.json (manifest-related fields)
{
"name": "Client",
"deploymentPath": "/client",
"buildPath": "/dist",
"themeColor": "#1c1c1c",
"icons": [
{
"src": "core-assets/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "core-assets/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
]
}
// → dist/manifest.webmanifest
{
"name": "Client",
"short_name": "Client",
"theme_color": "#1c1c1c",
"background_color": "#ffffff",
"display": "standalone",
"scope": "/client/",
"start_url": "/client/",
"icons": [ ... ]
}
The CLI#
offline-postbuild # reads ./offline.json
offline-postbuild --config ./config/offline.json
Inside buildPath, in order:
-
build-timestamp.txt— the build'sDate.now(), the new-version detector; - collects assets by the config patterns (its own outputs are excluded, so re-runs never precache a stale worker);
-
critical-assets.json— the deployment-prefixed critical list; -
manifest.webmanifest— the web app manifest, withscope/start_urlderived fromdeploymentPath; -
sw-min.js— the worker, generated from the template with your config baked in and minified in memory (no intermediatesw.jsever touches the disk); -
injects the bootstrap script and the
manifest
<link>as the first thing inside<head>of the built index.
The injected block is fenced with marker comments; a re-run replaces it instead of stacking a second copy. Running the CLI twice produces byte-identical HTML.
The service worker#
The generated sw-min.js routes every fetch by four
rules, first match wins:
A request carrying X-Cache-Only: true is answered
from the matching cache, or failed without touching the
network. This is the tracker's per-asset probe: a failed probe
means "not cached yet", never "server said no".
Alongside the fetch rules, a message handler
answers offline:cache-diff with the assets
missing from the caches — one cache.keys() per
cache instead of one request per asset. The tracker takes that
path for long lists and falls back to the probes when the
worker comes from a build that predates the handler.
The deployment root, the index pathname and everything under
any spaRoutesPaths prefix are served with
index.html — fresh from the network when
possible, from the index cache when not.
Every pathname in the generated critical + lazy lists. Cached on first successful fetch, then served from the cache forever — a new build's timestamp check resets it.
Matched by url prefixes and/or glob patterns into
data-group-<name> caches, with
maxSize eviction where configured.
Asset matching compares url.pathname against the
generated lists verbatim. Query strings are ignored by the
browser's pathname, but a mapAssetUrl that rewrites
the pathname would make the tracker request URLs the
worker does not recognize as assets — keep mapped pathnames
identical to the built files.
The bootstrap script#
Injected by the CLI as the first script in
<head>, in plain pre-ES2015 JS, so it runs before
any bundle on any browser. Four jobs:
-
Publish the build config.
window.__OFFLINE_CONFIG__ = { deploymentPath }— the convention that letsregisterServiceWorker()work with no arguments, at any moment, in any order. -
Fix the worker scope. A visit to
/app(no trailing slash) is redirected to/app/— a worker registered at/app/sw-min.jsdoes not control the slashless URL. -
Recover from chunk errors. A
ChunkLoadErrormeans a lazy chunk was requested that is neither cached nor reachable — the page reloads once, as soon as the connection returns. -
Detect new builds.
build-timestamp.txtis fetched cache-bypassing and compared with the remembered one; a mismatch clears every cache so the worker re-caches the new version.
Runtime
OfflineTracker#
A class used purely through its static members — the constructor is private and throws. There is exactly one offline state per app, and the type system (plus a runtime guard) makes an accidental second one impossible.
class OfflineTracker {
static init(options?: OfflineTrackerOptions): void;
static reInit(options?: OfflineTrackerReInitOptions): void;
static registerServiceWorker(): void;
static get status(): OfflineStatus;
static get isOfflineReady(): boolean;
static get wasOfflineReadyLastSession(): boolean;
static subscribe(listener: (isOfflineReady: boolean) => void): OfflineTrackerUnsubscribe;
static whenOfflineReady(): Promise<void>;
static stabilizeCaching(): Promise<void>;
static destroy(): void;
}
Every stateful member throws before init() — a
default value would be a plausible-looking lie about the cache.
The two exceptions: registerServiceWorker() is
deliberately independent, and destroy() is a safe
no-op.
init(options?)#
Synchronous, returns nothing — it kicks the whole process off and
the async work runs in the background. The first call verifies the
NetworkConnection precondition (throws synchronously
when missing), wires the controllerchange re-track and
the reconnect auto-stabilization, fetches
critical-assets.json through a client-side cache-first
bucket (@dmytromykhailiuk/cache-request, bucket
offline-critical-assets): the cached copy is served
when present — no network round-trip per session — and a miss goes
to the network, HTTP-cache-bypassing, caching the ok response. The
bucket is invalidated by the bootstrap's
new-build cache wipe, so a fresh build means a fresh list. With no
cached copy the fetch is retried until an ok response
arrives (the CLI always generates the file, so a 404/5xx is
transient deploy state; offline stretches are waited out via
NetworkConnection.continueWhenOnline()) — and starts
tracking: once a worker controls the page, ask it what is cached
(one offline:cache-diff message, or an
X-Cache-Only request per asset), fetch what's missing
(the worker caches it), re-check on retryDelay, park
while offline, resume on reconnect. Critical assets gate readiness
directly; additionalLazyLoadAssets load in the
background and flip
isOfflineReady when done.
Callable once — the configuration it resolves is
final, and a second call throws. Restart tracking with
reInit(); reconfigure via destroy() + init().
Turns the whole tracker into an inert no-op — for dev
environments, where the offline layer does not exist. Pass
your dev flag, e.g.
disabled: import.meta.env.DEV. While disabled
nothing runs — no requests, no listeners,
registerServiceWorker() skips registration,
stabilizeCaching() resolves without doing
anything, reInit() calls stay no-ops — and
isOfflineReady stays false. The
NetworkConnection precondition is skipped too.
Use destroy() to leave it.
Called before every request the tracker makes — the
list, the cache probes, the warming loads — with a
Headers instance holding what is about to be
sent; returns the Headers to actually send
(mutating and returning the same instance is fine). Attach an
auth token here — the headers analog of
mapAssetUrl; see Recipes.
Transforms every asset URL (built list + additional) before it is requested — e.g. substituting the current theme or translation version into a templated path.
Consulted before an automatic
stabilizeCaching()
run after a reconnect. Return false to skip it —
say, while the user is mid-upload.
Milliseconds to wait after serviceWorker.ready
before loading, giving a fresh worker time to take over the
page.
Milliseconds between load-and-recheck rounds.
Milliseconds to wait after the connection returns before a paused loop resumes.
Extra assets that must be cached — on top of
critical-assets.json — before the app counts as
ready. Later reInit() calls merge into this list:
every cycle tracks the accumulated, deduplicated union.
Extra assets loaded in the background. They gate
isOfflineReady but load without blocking the
critical set. Merged across reInit() calls, like
the critical ones.
reInit(options?)#
// The flow-manager table arrived and brought its own assets:
OfflineTracker.reInit({
additionalCriticalAssets: table.additionalAssetsToLoad,
additionalLazyLoadAssets: table.additionalLazyLoadAssets,
});
Restart tracking with more assets — the only two options it takes
are additionalCriticalAssets and
additionalLazyLoadAssets; the configuration (hooks,
delays, disabled) belongs to init() and
cannot change here. Synchronous, returns nothing, callable any
number of times. Throws before init(); a no-op while
initialized with disabled: true.
-
Merging: the passed lists join the deduplicated
union of everything given to
init()and earlierreInit()calls — a later caller can never drop what an earlier one declared critical. The union always sits on top ofcritical-assets.json, which is not refetched. - Superseding: the previous cycle stops silently; readiness resets and is re-verified from scratch.
- controllerchange: a new worker taking over re-runs tracking with the accumulated union automatically — a new worker means a new, empty cache.
registerServiceWorker()#
Registers origin + deploymentPath + "/sw-min.js" with
updateViaCache: "none", the deployment path taken from
the CLI-injected window.__OFFLINE_CONFIG__ — the single
source of truth. Synchronous, returns nothing — like
init(), it kicks the registration off and the browser
does the rest in the background. No parameters, independent of
init() — call it at any moment, in either order. A
silent no-op where service workers do not exist (SSR, dev without
the postbuild, unsupported browsers). When
NetworkConnection is initialized, the registration
survives connection drops via
restartIfNotFinishedWhenOnline; a registration that
genuinely fails is reported to the console — there is no meaningful
recovery the caller could do.
status & isOfflineReady#
OfflineTracker.status;
// { criticalAssetsLoaded: boolean, lazyAssetsLoaded: boolean, allAssetsLoaded: boolean }
OfflineTracker.isOfflineReady; // shortcut for status.allAssetsLoaded
status returns a fresh snapshot object on every read;
isOfflineReady is the single boolean the app usually
cares about. Both throw before init().
X-Cache-Only request is a plain network GET the server
answers with 200 — a full cache over an empty one. On a
first-ever visit the status therefore stays all-false
until the freshly registered worker activates, claims the page and
fires controllerchange, which restarts tracking. A page
that stays uncontrolled — registerServiceWorker() never
called, or no service worker support — reports false
forever and says so once in the console.
wasOfflineReadyLastSession#
OfflineTracker.init({ disabled: import.meta.env.DEV });
// Paint straight away instead of flashing a spinner on every reload…
splash.hidden = OfflineTracker.wasOfflineReadyLastSession;
// …then correct it the moment the cache has actually answered.
OfflineTracker.subscribe((isReady) => {
splash.hidden = isReady;
});
Readiness is remembered in localStorage between
sessions and read back once at init(), so the UI has
something better than "not ready" to paint in the moment before the
first cache check answers. Always false when
initialized with disabled: true; throws before
init(). The injected bootstrap drops it together with
the caches on a new build.
isOfflineReady, never appears in status,
never resolves whenOfflineReady() and never fires
subscribe(). The browser can evict Cache Storage on its
own — quota pressure, Safari's 7-day rule, a selective "clear cached
files" — while localStorage survives, so this flag can
read true over an empty cache. Use it to pick the
initial UI state; never to decide the app may go offline.
subscribe(listener)#
const unsubscribe = OfflineTracker.subscribe((isOfflineReady) => {
banner.hidden = isOfflineReady;
});
unsubscribe(); // detach; calling it again is a no-op
Fires on every change of isOfflineReady — in
both directions (re-initializing against a wiped cache drops
readiness first). The listener is not called on subscription: the
current value is already available synchronously. A throwing
listener is contained and reported; subscribing or unsubscribing
from inside a listener is safe; destroy() drops every
subscription without a final call.
whenOfflineReady()#
Resolves immediately when ready, otherwise on the next transition to
ready. destroy() rejects pending waiters — leaving them
hanging would deadlock the code awaiting them.
stabilizeCaching()#
The repair path. Without a controlling, active worker the cache state is untrustworthy — every cache is deleted and the page reloads to start clean. With a healthy worker, the still-missing assets get one load-and-recheck round, flipping readiness if that closed the gap.
It runs automatically after the connection comes
back while the app is not ready (gated by
shouldStabilize), and it can be called manually at any
time. Before the first tracking cycle populates the missing-asset
sets it is a deliberate no-op — an empty "missing" set proves
nothing.
destroy()#
Undo init(): stop every loop, detach the
controllerchange and network listeners, drop
subscriptions without a final call, reject pending
whenOfflineReady() waiters, reset all state. A no-op
when not initialized. Built for tests and HMR teardown — production
apps normally never call it.
Recipes#
Auth-protected assets#
OfflineTracker.init({
modifyRequestHeaders: (headers) => {
headers.set("Authorization", getToken());
return headers;
},
});
The worker forwards the header named by
authHeaderPath in offline.json when it
fetches on the page's behalf.
Versioned asset paths#
OfflineTracker.init({
mapAssetUrl: (url) =>
url
.replace("{theme}", currentTheme())
.replace("{lang}", currentLanguage()),
});
Route-scoped stabilization#
OfflineTracker.init({
shouldStabilize: () =>
!location.pathname.includes("/upload") &&
!location.pathname.includes("/completed"),
});
Assets that arrive with data#
// Call reInit() whenever the server tells you what else matters —
// the new assets merge into the tracked union, tracking restarts,
// the list is not refetched.
onTableLoaded((table) => {
OfflineTracker.reInit({
additionalCriticalAssets: table.additionalAssetsToLoad ?? [],
additionalLazyLoadAssets: table.additionalLazyLoadAssets ?? [],
});
});
TypeScript#
Everything is typed; the package ships ESM + CJS with
.d.ts for both. Exported types:
OfflineTrackerOptions ·
OfflineTrackerReInitOptions ·
OfflineStatus · OfflineTrackerListener ·
OfflineTrackerUnsubscribe ·
OfflineGlobalConfig · OfflineConfig ·
OfflineDataGroup ·
OfflineManifestDisplay ·
OfflineManifestIcon.
OfflineConfig / OfflineDataGroup describe
offline.json — handy for generating the config from
typed build scripts. The
window.__OFFLINE_CONFIG__ global is declared on
Window, so reading it in app code type-checks out of
the box.