A service worker can intercept requests without a page being open, but it is not a permanently running proxy and it does not make an application offline-first by itself. The browser may terminate it between events, delay an update, evict stored data, deny background work, or keep an older worker controlling tabs while a newer script waits. Designs that assume one immortal process and one current cache eventually serve incompatible assets or lose queued writes.

Offline-first engineering is therefore a consistency problem organized around a lifecycle. Immutable resources need version-coherent publication. Mutable reads need explicit freshness semantics. Writes need durable intent, stable identity, conflict handling, and visible synchronization state. Every background opportunity must have a foreground recovery path. Once those contracts are explicit, a service worker becomes a useful coordinator rather than a hidden second application runtime.

Model the Service Worker as a State Machine

Registration associates a script URL and scope with an origin. When the browser discovers a byte-different script, it starts an update without immediately replacing the worker that controls existing pages. The new worker passes through installing, then usually waiting, then activating and activated. A page is controlled only after navigation or an explicit claim, and a worker can be activated yet control no current client.

stateDiagram-v2 [*] --> Installing: register or update found Installing --> Redundant: install rejects Installing --> Waiting: install succeeds Waiting --> Activating: old worker has no clients Activating --> Activated: activation succeeds Activating --> Redundant: activation rejects Activated --> Redundant: replaced or unregistered

The waiting state protects open pages. Their JavaScript was loaded under the old deployment and may expect old asset names, message formats, or cached data. Replacing the controller underneath them can create a mixed-version application. skipWaiting() and clients.claim() shorten rollout but remove that protection; they are safe only when old pages and the new worker are deliberately compatible.

Event handlers must complete observable work through event.waitUntil(...) or, for a fetch, event.respondWith(...). Starting an asynchronous task and returning does not keep the worker alive. Global variables are caches of convenience only. The next event may run in a fresh worker instance, so durable queue state and migration markers belong in persistent storage.

Treat lifecycle transitions as deployment states. Log the worker build identifier, expose when an update is waiting, and define whether users may finish work before reloading. An update button should coordinate tabs and confirm that unsaved local operations are durable before asking clients to navigate.

Publish a Version-Coherent Application Shell

An application shell commonly includes HTML, JavaScript, CSS, fonts, and icons needed to start the interface offline. Those files form a release unit. Caching each under stable names without a release boundary can pair yesterday’s HTML with today’s JavaScript or delete assets still needed by an older tab.

Use content-hashed asset names and a versioned shell cache:

js
const BUILD = '2026-06-15.1';
const SHELL_CACHE = `field-shell-${BUILD}`;
const SHELL_ASSETS = [
  '/offline.html',
  '/assets/app.4d2f8a.js',
  '/assets/app.80c731.css',
  '/assets/icons.18d6c2.svg',
];

self.addEventListener('install', (event) => {
  event.waitUntil((async () => {
    const cache = await caches.open(SHELL_CACHE);
    await cache.addAll(SHELL_ASSETS);
  })());
});

cache.addAll rejects if a required response cannot be obtained, so a partially downloaded release does not become the installed worker’s declared shell. Installation should cache only a bounded, known manifest. Pulling every application response into install makes updates fragile and consumes storage before the user needs it.

Activation can remove obsolete caches, but deletion policy must account for old clients. If the new worker activates only after the old worker has no clients, deleting older shell caches is straightforward. If activation is forced, the new worker may need to retain one or more compatible generations until all clients report their build or reload. Cache names are coordination records, not merely housekeeping labels.

Never derive the shell manifest by parsing deployment HTML inside the worker. Produce it from the same build that emits hashed assets, and fail deployment if a listed resource is missing. Keep the offline fallback in that manifest and ensure it has no dependencies absent from the shell.

Choose Fetch Policies by Resource Semantics

One cache strategy cannot preserve every resource’s meaning. Classify requests before intercepting them:

Request class Suitable default Required bound
Hashed shell asset Cache first Immutable name and release manifest
Top-level navigation Network first, offline fallback Deadline and version-compatible fallback
Public slowly changing data Stale while revalidate Maximum acceptable staleness
User-specific data Network or validated local model Authorization, partitioning, freshness display
Mutation Durable outbox, then network Idempotency and conflict policy

The fetch handler can encode only the narrow shell and navigation rules:

js
self.addEventListener('fetch', (event) => {
  const request = event.request;
  const url = new URL(request.url);

  if (request.method !== 'GET' || url.origin !== self.location.origin) return;

  if (request.mode === 'navigate') {
    event.respondWith((async () => {
      try {
        return await fetch(request);
      } catch {
        const fallback = await caches.match('/offline.html');
        return fallback ?? new Response('Offline', { status: 503 });
      }
    })());
    return;
  }

  if (url.pathname.startsWith('/assets/')) {
    event.respondWith((async () => {
      const cached = await caches.match(request);
      return cached ?? fetch(request);
    })());
  }
});

Do not cache every successful GET indiscriminately. Personalized responses can cross account boundaries after logout, opaque cross-origin responses cannot be inspected, and unbounded URL variants can exhaust quota. Respect application-specific cache directives and make account changes clear or delete user-scoped local data.

Offline is also not the only network failure. A captive portal may return HTML with status 200 for an API URL; an authenticated endpoint may return 401; a server may return stale-but-valid data. Validate status, content type, and schema before replacing known-good local state. Define a network deadline where fallback is appropriate rather than making users wait indefinitely for an interface that claims to work offline.

Work Through an Offline Inspection Outbox

Consider a field-inspection app. A technician opens assignment A-42, records a checklist result and note, then loses connectivity. The UI must show the edit immediately, survive page and browser restarts, synchronize at most once in business effect, and report a conflict if a supervisor changed the same assignment meanwhile.

Represent the local mutation as durable intent:

ts
type PendingInspection = Readonly<{
  operationId: string;
  assignmentId: string;
  baseVersion: number;
  patch: Readonly<{
    passed: boolean;
    note: string;
  }>;
  createdAt: string;
  state: 'pending' | 'sending' | 'conflict';
  attempts: number;
}>;

In one IndexedDB transaction, write the pending operation and update the local projection shown by the page. Only then tell the user the change is saved offline. A memory queue or message sent only to the worker can vanish when the process stops. The page and worker may both attempt synchronization, so claiming work must also be transactional: select a pending operation and mark it sending with a lease or attempt record before issuing the request.

Send operationId as an idempotency key and baseVersion as a precondition. The server stores the operation result atomically with the assignment update. A retry with the same key returns the original result; it does not apply the patch twice. If the current assignment version no longer matches baseVersion, the server returns a conflict with enough current state for the application to resolve it.

On success, a local transaction records the authoritative server version and removes the outbox item. On a transient network failure, return it to pending with a bounded backoff. On authentication failure, stop automatic attempts and ask the user to sign in; do not discard intent. On validation failure, preserve the rejected edit with a readable error. On conflict, show both local intent and current server data rather than silently choosing last write wins.

The outbox gives offline work an auditable state machine. The service worker is one possible drain mechanism, while the durable operation record remains the source of truth.

Coordinate Updates Without Mixing Releases

Update races appear when a new worker, old pages, and several cache generations coexist. A message from a page can arrive at a worker whose protocol differs from the sender’s expectations. An old page can request a hashed asset after activation cleanup. A user can have unsaved outbox entries while the app asks for a reload.

Version every page-worker message:

ts
type WorkerMessage =
  | { protocol: 3; type: 'get-sync-state' }
  | { protocol: 3; type: 'request-sync' }
  | { protocol: 3; type: 'prepare-update'; pageBuild: string };

Reject unsupported protocols with a structured response rather than ignoring them. Keep message formats backward compatible across the maximum planned overlap, or decline forced activation. The new worker can notify clients that an update is waiting; each page persists edits, reports whether it can reload, and lets the user choose an appropriate moment. Use BroadcastChannel or client messaging to coordinate, but remember that tabs can disappear before acknowledging.

Database schema evolution needs the same discipline. An activating worker must not irreversibly migrate shared local data while an old page still expects the old shape. Prefer additive stores and fields, readers tolerant of both versions, and a later cleanup release. If a migration must be exclusive, block old clients explicitly and preserve a recovery copy or restartable migration marker.

Avoid unconditional skipWaiting() copied from tutorials. It is reasonable for a small static site with no page-worker protocol and fully immutable assets. It is risky for an offline editor with queued writes and local schema. Activation policy is part of application compatibility, not a generic performance tweak.

Make Background Work Opportunistic

Background Sync can ask the browser to deliver a synchronization event after connectivity returns. Periodic Background Sync, push, and related capabilities can create additional opportunities. Availability, timing, quotas, permissions, battery policy, platform support, and user settings vary. None is a guaranteed job scheduler.

Register background work only after the outbox transaction commits. In the handler, call event.waitUntil(drainOutbox()), process a bounded number of operations, and leave remaining work durable. Concurrent triggers must be harmless: a page becoming visible, an online event, a manual retry button, and a sync event may all invoke the same lease-based drain function.

The foreground path is mandatory. On launch and when connectivity appears usable, the page asks for synchronization. A visible status reports pending, sending, conflict, authentication-required, and last successful synchronization. “Online” is only a hint; the actual request determines reachability. Conversely, a failed request should not globally declare the device offline when one endpoint may be unavailable.

Bound retries by error class. Retry transport failures, selected server failures, and explicit throttling guidance. Do not loop on invalid data, authorization denial, or version conflict. Add jitter and a maximum attempt frequency so many reconnecting clients do not synchronize simultaneously. Large uploads may require resumable protocols and explicit user control rather than hoping a short worker event finishes them.

If storage is evicted, background permission is denied, or the worker is unregistered, the app should degrade honestly. It cannot promise durable offline work unless persistence was actually confirmed. Request persistent storage only when the product value justifies it, monitor the result, and let users export or recover critical unsynchronized data where loss would be unacceptable.

Recover from Partial and Corrupt State

Every step can stop between effects. The network request may succeed before the client records success. Cache installation may finish while activation cleanup does not. A local transaction may abort under quota pressure. Recovery must infer state from durable records rather than from the last in-memory callback.

Idempotency resolves the ambiguous-send case: retrying an operation ID asks the server for the same business result. Transactional outbox updates prevent a queue item from disappearing before its local projection is committed. Restartable migrations record a version only after all required changes commit. Cache publication uses a new name and switches readers only after the complete manifest is present.

Validate cached and stored data when reading it. If a cached API response fails its schema or belongs to a different account, delete or quarantine it and fetch anew. If a shell asset is missing, fall back to the network or the minimal offline page instead of constructing a partly current interface. Keep a recovery route outside complex application code so users can clear site data, reload, or export pending operations when normal startup fails.

Logout is a consistency transition. Stop drains, revoke server credentials, remove user-specific cached responses and projections, and decide what happens to unsynchronized edits. Silently uploading them under the next account is unacceptable. The product may require synchronization before logout, encrypted local retention tied to the original account, or explicit discard confirmation.

Operationally, classify failures with stable codes: install resource missing, quota exceeded, schema mismatch, idempotency lookup failed, conflict, authentication required, and retry exhausted. Avoid logging note contents or complete cached responses. Counts and build IDs are usually enough to identify a broken release without collecting sensitive offline data.

Verify Lifecycle, Offline, and Upgrade Paths

Unit-test fetch classification, outbox transitions, retry decisions, and conflict reducers without a browser. Property-based tests can generate operation sequences and assert that acknowledged mutations are absent from the queue, pending mutations remain recoverable, and one operation ID never produces two business effects in a fake server.

Browser integration tests need controlled workers and distinct builds. Cover first install, a failed precache, reload into controlled state, an update waiting behind an old tab, user-approved activation, forced tab closure, and upgrade with pending writes. Test two tabs requesting a drain concurrently. Simulate termination between network success and local acknowledgement, then verify an idempotent retry converges.

Exercise offline navigation, slow rather than absent networking, invalid 200 responses, 401, 409, 429, server failure, quota errors, and storage deletion. Verify that user-specific data disappears or remains correctly bound on logout. Test a skewed deployment where old HTML meets the new worker and where the current page requests an older hashed asset. A release is not coherent merely because a warm happy-path reload works.

In production, measure install and activation failures by worker build, waiting-worker age, controlled-client version, cache population failures, outbox depth and oldest age, synchronization outcomes, conflicts, and recovery actions. Avoid using only a global “offline usage” count; it cannot reveal whether writes are stranded. Roll out workers gradually, retain server compatibility with prior client schemas, and keep a kill path that stops interception or synchronization without deleting pending intent.

An offline-first application is successful when interruption becomes an explicit state rather than an exceptional guess. The service worker lifecycle controls which coordinator serves which clients. Versioned manifests keep shell resources coherent. Request-specific policies preserve freshness and privacy. A transactional, idempotent outbox protects mutations, while background events merely create extra chances to drain it. Test termination and version overlap as normal conditions, and the application can recover even when the browser schedules work, updates code, or removes storage differently than the happy path assumed.