A service worker should have one job

D4U7 became easier to update and recover when its service worker owned versioned application assets, not documents, drafts, synchronization, and every meaning of offline.

D4U7's first service worker was a small application server hiding inside the browser.

It cached the shell, intercepted navigation, stored API responses, retried queued contributions, decided when data was stale, migrated IndexedDB, displayed notifications, and cleaned old records. Every offline feature seemed to have a service-worker-shaped answer, so I kept adding responsibilities.

Then an update installed while an old editor remained open. The new worker read the local outbox using a changed schema, served a new JavaScript chunk to the old page, and classified a cached room response as fresh. The draft survived. The application no longer agreed with itself about what the draft meant.

I reduced the service worker to one principal job: deliver a versioned application shell and its static assets safely across online, offline, old, and new clients.

Documents, drafts, intent, and synchronization returned to explicit product boundaries.

“Offline” had hidden four stores

The original design referred to one offline cache. In practice, the browser held different kinds of state:

  1. Application assets: HTML fallback, JavaScript, CSS, icons, and fonts tied to a release.
  2. Server projections: room snapshots and immutable revisions that could be fetched again.
  3. Local intent: drafts, outbox operations, conflict work, and unknown-outcome receipt queries.
  4. HTTP cache state: normal browser responses governed by request headers and URL semantics.

They had different owners and failure consequences.

Static assets fit Cache Storage and the service-worker fetch path. Room projections needed explicit revision identity and belonged in the application's IndexedDB model. Local intent was irreplaceable and required transactional migration, user-visible recovery, and account-aware lifecycle. The browser HTTP cache remained useful for ordinary network behavior.

Calling all four “cache” encouraged the service worker to apply URL and freshness heuristics to data whose correctness depended on room revisions.

The first architectural improvement was vocabulary. Cached assets, stored projections, and pending intent stopped sharing one name.

Asset identity came from the build

M31V and R7K1 already produced immutable asset names. D4U7 extended that idea into a release manifest:

{
  "release": "2020.08.18-4f2c",
  "shell": "/app-shell.8d19.html",
  "assets": [
    "/static/app.71ac.js",
    "/static/app.39b2.css",
    "/static/icons.a033.svg"
  ],
  "clientProtocol": 7,
  "localSchema": 5
}

The service worker installed one named asset set and verified that every required response was successful before declaring the release ready. Hash-named assets could coexist across versions. The cache name included the release identity.

I did not use a mutable app.js URL with a cache-first policy. An old byte sequence under a current name creates a version that no manifest can explain.

The shell referenced immutable assets from the same build. A navigation fallback served the shell belonging to one complete installed release, not whichever individual files happened to be cached.

Build identity made the offline application reproducible. The worker did not decide asset compatibility by guessing from filenames.

A new service worker can install while pages controlled by the previous worker remain open. The platform's waiting behavior initially felt inconvenient. It protected an important boundary.

Installed meant the new release's required assets were available. It did not mean every current page could safely switch.

An old page might dynamically import a chunk from its own asset graph. If the new worker deleted old assets or mapped mutable URLs differently, that import could fail. The page might also use client protocol 6 while the new release expected protocol 7.

I treated the waiting worker as a prepared release. It announced its release, protocol, and local-schema versions to controlled pages. Each page could report whether it had irreplaceable local work and whether the update was compatible.

Compatible updates could activate on the next ordinary navigation. Incompatible updates prompted a deliberate reload only after drafts and outbox records were migrated or exported.

I stopped calling skipWaiting() unconditionally. Immediate activation was a product decision with state consequences, not a universal upgrade to freshness.

Even after a new worker activated and claimed clients, an existing page still executed old JavaScript. Control and code version could differ.

The worker did not assume that taking control upgraded the page. It responded to asset requests by immutable URL and supported a bounded overlap of recent asset caches while old clients existed.

A small version handshake let the page detect the controlling worker's release. If the combination was compatible, ordinary fetch behavior continued. If not, the page displayed an update state and avoided commands whose protocol contract was uncertain.

I preferred a clear reload requirement over clever cross-version adaptation inside every message. The important part was preserving local intent before reload and ensuring the new page could read it.

The service worker never edited a draft to make it compatible. Local-schema migration belonged to application code that could present failure and export choices to the person.

Activation changed the network intermediary. It did not magically replace JavaScript already alive in every tab.

API responses left the generic fetch handler

The first worker used stale-while-revalidate for room API responses. It made repeat visits fast and introduced an invisible second source of truth.

The page requested room revision 38. The service worker returned a cached response for revision 31 immediately and fetched a new response in the background. The application could not tell that the bytes came from a cache unless I added custom metadata. It briefly rendered old lifecycle and permission state as current.

I removed generic API caching from the worker. D4U7 fetched through its application data layer, which understood room cursors, snapshots, deltas, authentication, and stale state. It stored accepted projections in IndexedDB with explicit revision and confirmation time.

The application could then say showing revision 31 while checking for updates. It could refuse a final decision while stale and still allow reading.

HTTP caching remained possible for immutable resources and server responses whose semantics supported it. The service worker stopped converting every successful GET into offline data architecture.

A fast stale response is useful only when the consumer knows it is stale and knows what operations remain safe.

Intercepting failed mutation requests and queuing them automatically looked elegant. It also erased operation semantics.

The service worker saw a request body and URL. It did not necessarily know whether the action could auto-deliver after the room changed, whether current authority was required, or how to present a merge. Retaining arbitrary requests could also retain credentials or payloads under the wrong account lifecycle.

D4U7 created outbox operations deliberately before network delivery. Each had a stable ID, base revisions, authority context, payload version, and delivery policy. The application presented their state.

The service worker could notify an open client that connectivity appeared available. It did not turn a failed fetch into product intent. Where background execution was supported and safe, a narrowly defined application task could ask the outbox boundary to process auto-deliver operations; correctness did not depend on it.

This removed a magical failure path. A mutation entered the durable outbox because the product chose to preserve it, not because the network happened to fail after fetch() began.

Offline navigation needed a useful response. Serving the complete last room HTML from a generic cache would mix authentication, content, and freshness.

The worker served a minimal versioned application shell for navigations within the app scope when the network was unavailable. The shell could start, open the local data layer, identify the account/device context, and display stored room projections with their revisions.

It did not embed private room content in a broadly keyed HTML cache. It did not claim that a route was current. If no provisioned local room existed, the page explained that this content had not been made available on the device.

Public informational routes could use ordinary cache behavior appropriate to their content. Authenticated room state followed the explicit local store.

The fallback's job was to give application code a safe place to recover, not to counterfeit a successful online navigation.

Keeping it small also reduced update risk. The shell contained enough structure to explain offline and update states without carrying the whole product into one fragile cached document.

Cache cleanup respected living clients

My first activation handler deleted every cache whose name did not match the new release. An old page then requested a lazy-loaded chunk from its release and received a network failure while offline.

I retained asset sets for controlled client releases and a small bounded fallback window. Pages reported their release during the version handshake. Cache cleanup removed an old set only when no living client claimed it and the retention window had passed.

Browser crashes and abandoned clients meant the worker could not rely on a perfect departure event. Time bounds prevented indefinite accumulation. If an exceptionally old page returned after its assets expired, the shell explained that an update was required and protected local intent before reload.

Immutable shared assets could be deduplicated by their content URL even when referenced by several manifests. The cache index recorded manifest membership rather than copying bytes under mutable aliases.

Cleanup was not an aesthetic step. Removing an asset was a compatibility decision about clients that might still request it.

I had originally debated cache-first, network-first, and stale-while-revalidate as global preferences. The right choice depended on the resource.

  • Hash-named build assets used cache-first after verified installation because the URL identified immutable content.
  • The release shell used the installed manifest and a bounded network update check.
  • Authenticated room data bypassed generic service-worker storage and entered the revision-aware data layer.
  • Uploads and mutations used explicit application operations, never blind fetch replay.
  • Optional remote evidence links remained network resources and did not silently become offline content.

Each handler was small because the resource contract was clear.

The worker also checked response status and type before caching. A captive portal, login HTML, or opaque failed response should not be stored under an application script URL. Required installation failed closed if the verified asset set was incomplete.

Caching policy became an outcome of identity and mutability rather than a performance slogan.

Update communication named the risk

The first update banner said New version available—refresh. People learned to dismiss it because the timing and consequence were unclear.

D4U7 distinguished:

  • Update ready: compatible; applied on next navigation or explicit refresh.
  • Refresh after saving: a local editor transaction is in its short critical section.
  • Review local work before updating: an outbox record needs migration or export.
  • Update required for connection: the server no longer accepts the old client protocol, but local work remains safe.

The page could list the number of drafts and pending operations affected without exposing their text in the banner. Refresh preserved the return location and restored focus appropriately.

I did not force an update during conflict resolution or while unsaved text existed only in memory. The editor persisted first. For a critical security update, the policy could shorten the grace period and restrict online operations while still offering draft export.

Update experience belonged to the product because the service-worker lifecycle met human work there.

Local schema migration was not an activate hook

Running IndexedDB migrations from the new service worker during activation seemed centralized. It lacked the interface and application semantics needed for failure.

The worker might activate with no visible page. It could be terminated. It might not know which account owned a draft or how an old operation kind should map to a new delivery policy. A failed migration could block all future fetch events.

The new application opened the database through a versioned migration layer. It classified records as irreplaceable intent or rebuildable projection. Captured fixtures from prior releases exercised each supported upgrade path.

The migration copied and validated intent before deleting old stores. Unknown operation shapes stayed exportable. Rebuildable caches could be discarded and fetched again.

The service worker's manifest advertised the required local-schema version so the page could plan the transition. It did not perform the transition itself.

Central code is not automatically the correct owner. Ownership requires the information and recovery surface needed to keep the invariant.

Browsers can evict storage under pressure. Private browsing, device cleanup, or user settings can remove caches and IndexedDB. A PWA cannot promise that local state exists forever merely because a write succeeded once.

D4U7 checked storage writes, surfaced failures, and encouraged export for particularly important unpublished drafts. Where the platform exposed persistence requests, the app could use them as an enhancement without treating the result as universal.

The service worker did not hide missing assets behind partial success. If the offline shell was incomplete, it reported the limitation when online rather than waiting for the next outage.

The data layer similarly distinguished provisioned-for-offline rooms from content never stored locally. A menu badge did not imply every attachment or history revision was present.

Offline support was a maintained state with evidence, not a one-time installation event.

This restraint kept the promise inside what the browser and product could actually verify.

Security boundaries became smaller

A service worker can intercept requests within its scope. That makes its scope, origin, update path, and imported code consequential.

I kept the registration scope limited to the application, served the worker over the required secure context, and avoided importing third-party scripts into it. Its build artifact was immutable and included in the release manifest.

Removing document and outbox ownership reduced the sensitive data it handled. The worker did not maintain a private API-response cache or store authorization headers for replay. It delivered application assets and a minimal shell.

Authenticated fetches still passed through the browser's network stack and could technically be observed by the worker's scope, so code integrity remained important. A narrow implementation reduced both review surface and accidental logging.

Cache keys did not include secret query values. Private content remained in the account-aware IndexedDB boundary and was cleared or retained according to explicit sign-out policy.

One job made the security story smaller enough to explain.

Testing crossed old and new versions

Service-worker tests that installed one version in an empty browser missed the difficult state.

I tested pairs and sequences:

  • Old page with old active worker.
  • Old page while new worker waits.
  • Old page after compatible new worker takes control.
  • Old page requesting a lazy asset while offline.
  • New page migrating old local intent.
  • Device returning after several supported releases.
  • Incomplete new asset set during install.
  • Cache cleanup with an old client still open.
  • Protocol incompatibility with a safe local draft.
  • Storage eviction followed by offline navigation.

The release harness built immutable manifests and used real browser registration where possible. Unit tests covered routing by resource contract; browser tests covered lifecycle and user explanation.

I included a recovery reset for development that removed rebuildable caches while exporting or preserving intent. “Unregister everything” was not an acceptable instruction for a study participant with a draft.

The tests treated coexistence as normal, not an edge case after launch.

The smaller worker enabled the richer application

After the reduction, the service worker did less:

  • Verify and store one versioned asset set.
  • Serve immutable assets from their manifest.
  • Provide a minimal offline navigation shell.
  • Coordinate a bounded update handshake.
  • Retain and clean asset versions according to client use.

D4U7 itself did more explicit work. The data layer owned room revisions. The outbox owned delayed intent. The editor owned drafts and conflicts. The update flow owned migration and explanation.

That distribution was not duplication. Each boundary held the semantics required for its decision.

The phrase “service worker should have one job” was intentionally stronger than the platform requires. Other products can use service workers for carefully designed background tasks, push, or response strategies. In D4U7, restraint corrected an architecture that had mistaken one execution context for one universal owner.

The worker made the application available. It did not get to decide which version of human work was true.