Skip to content

Service Worker Resource Fallback — Design Comparison

Executive summary

Service Worker significantly extends resource fallback coverage — especially for img, video, @font-face fonts, CSS url() subresources, and CSS @import requests that DOM Observer cannot easily perceive. But SW is not a strict superset of the current DOM Observer, Webpack adapter, and Vite adapter. It solves fetch-layer request fallback; it cannot fully replace page-side handling of script execution semantics, builder runtime Promises, SRI tag attributes, and first-load timing.

The recommended path is not SW-first, but layered: keep existing script and builder adapter ownership; introduce Hybrid SW for non-script resources and CSS subresources. If strict synchronous classic script ordering is needed later, use an opt-in ScriptSequencer at build and runtime — don't force that problem onto SW.

Current implementation status

Hybrid SW is implemented as opt-in. Vite/Webpack plugins generate a resource manifest, emit a SW asset, and preload manifest into the SW file so early subresources (images, background images, fonts) don't pass through to the primary CDN before the page postMessages config. The page runtime still registers the SW, sends follow-up config updates, and bridges SW postMessage events into rf:* DOM events.

Default SW path follows scope: scope: '/'/rf-sw.js, scope: '/app/'/app/rf-sw.js. Only when path is explicitly outside the scope directory do you need Service-Worker-Allowed.

Images and CSS backgrounds often use no-cors; SW may only see opaque responses without readable status. By default opaque responses are not failures. With serviceWorker.fallbackOnOpaque, the SW first probes with CORS: readable non-2xx responses enter fallback, while a CORS failure downgrades to no-cors and accepts the opaque response. The demo projects' .invalid domains exercise DNS/network failure, not opaque HTTP status handling.

Current baseline

The README TODO now keeps only capabilities such as strict synchronous classic-script ordering that are not implemented yet; Hybrid SW and image/font support already ship as opt-in capabilities. SW extends coverage, but it is not a full replacement for page-side script and builder semantics.

Runtime installs from packages/core/src/runtime/entry.ts:

ts
const ownership = createOwnershipRegistry();
const circuit = createCircuitRegistry(...);
const coordinator = createRecoveryCoordinator({ config: prepared, bus, circuit });

installObserver({ coordinator, ownership, log, sri: prepared.sri });
installWebpackAdapter({
  coordinator,
  ownership,
  log,
  chunkLoadingGlobals: config.webpackChunkLoadingGlobals,
});
installViteAdapter({ config: prepared, coordinator, ownership, log, ... });
installSystemJSAdapter({ config: prepared, coordinator, ownership, log });
installSwAdapter({ config, bus, log });

Page-side adapters now share RecoveryCoordinator, the ownership registry, a shared page circuit, and the hook bus; they no longer each run the same Resolver directly. Concurrent recovery for the same owner + logicalKey reuses one in-flight recovery Promise, while duplicate takeover across owners is rejected during ownership admission. The Service Worker keeps its own fetch-layer resolver (createSwResolver()) and cooperates with the page through manifest preload and the event bridge.

packages/core/src/runtime/observer.ts captures error for <script> and <link rel="stylesheet"> in the capture phase, then replaces with retry or fallback URLs. Each replacement tag listens to its own load/error to settle that recovery attempt; the Observer does not globally capture load. It does not handle <img>, video, fonts, or CSS internal url()/@import. It documents sync classic script limits: after failure, replaceChild cannot reorder scripts that already continued executing.

packages/core/src/runtime/adapter-vite.ts handles Vite dynamic import() Promise semantics, module map failure cache busting, and vite:preloadError preventDefault() plus URL extraction for rule gating. The current runtime does not record page-side failure from the preload event back into the old resolver path; these behaviors are not expressible as fetch success/failure alone.

packages/core/src/runtime/adapter-webpack.ts, packages/webpack-plugin/src/index.ts, and the injected runtime bridge handle Webpack async chunks, __webpack_require__.l, data-webpack ownership, and CSS chunk promise rejections that would otherwise short-circuit Promise.all. They preserve Webpack loader/callback semantics but delegate recovery decisions to the page Coordinator, so concurrent requests for one logical chunk can share one in-flight recovery Promise.

Capability comparison

Script

Classic script, module script, Webpack async chunk, Vite dynamic import, and SystemJS are not the same resource type.

Current approach: Observer for entry <script>, Webpack adapter/runtime bridge for async chunks, __RF__.load() for Vite dynamic import, SystemJS instantiate hook. They switch URLs and handle module cache, builder Promises, loader markers, and events; page-side ownership and in-flight sharing are centralized in RecoveryCoordinator.

SW on a controlled page can deliver a successful script response for fetch-layer fallback — valuable when SW controls the request. But SW cannot guarantee early first-visit scripts are controlled, nor modify original <script integrity="...">. When SW cannot fix fetch, page adapters still handle failed Promises, cache bust, and events.

Conclusion: SW can improve script success rate; in the original rollout split it should not replace existing script adapters in the first shipped phase.

Style and CSS subresources

Observer covers top-level <link rel="stylesheet"> and some runtime-injected CSS chunk <link> tags. It cannot see CSS @import failures or background-image: url(...), @font-face src: url(...) subresource failures.

SW naturally fills this gap when the page is controlled — internal CSS requests go through fetch.

Top-level stylesheets need care: Observer + SW on the same <link> → duplicate retry, event chaos, inflated circuit counts. In the original shipped rollout split, the safer move was: SW owns CSS subresources and optional style destination; Observer keeps the top-level stylesheet boundary.

Conclusion: CSS url(), @font-face, @import are high-value SW targets; top-level stylesheets need ownership design.

Font

@font-face font requests suit SW fallback — fills a current gap.

Fallback URLs must satisfy browser requirements: CORS headers for cross-origin fonts; MIME, CORP, cache policy. SW cannot override security rejection.

Conclusion: Font fallback is supported with SW; document and test CORS/MIME prerequisites.

Image and media

img, picture, CSS images, video, audio lack script execution and builder Promise semantics — best SW MVP targets.

Many cross-origin images use no-cors → opaque response. SW reliably fallbacks on fetch rejection (DNS/network); opaque HTTP 404 may be indistinguishable from success.

Conclusion: Images/media are Hybrid SW MVP priorities; document opaque response limits.

Fetch and business APIs

SW can intercept page fetch()/XHR, but resource fallback should not default to business APIs. APIs have auth, idempotency, credentials, status semantics — different from static CDN fallback.

Future fetch destination support should be explicit opt-in with path rules (/assets/, .js, .css, etc.).

Conclusion: Don't include business APIs by default.

Worker and SharedWorker

Page runtime needs window, document, DOM events — no Worker support. SW ≠ automatic Worker support.

SW intercepts some client requests in scope; Worker lifecycle, importScripts, module workers, CSP need separate modeling.

Conclusion: Worker support stays independent TODO; not SW MVP criteria.

Service Worker boundaries

SW controls page clients, not CDN origin

Page at https://app.example.com/ registers SW from same origin; controls scope clients. SW can intercept https://cdn.example.com/assets/a.js from controlled pages and fallback fetch.

SW cannot install on cdn.example.com, control direct CDN URL visits, or out-of-scope iframes/other origins.

First visit cannot be fully covered

SW register/install/activate/claim is async. First visit early <script>, <link>, images, fonts may complete before fetch events. clients.claim() and reload help but cannot precede first HTML parse with an active controller.

Inline runtime and Observer remain valuable for first lifecycle DOM failures.

SW cannot modify HTML tag attributes

Observer strips/preserves integrity per sri when replacing tags. SW only returns different responses — original integrity, nonce, crossorigin, referrerpolicy unchanged. SRI hash mismatch on fallback CDN fails browser verification even if SW fetch succeeded.

Opaque response limits

no-cors → opaque response: no status, ok, headers, body. SW cannot reliably distinguish usable image vs opaque 404 HTML. Network errors and fetch rejections are reliable; opaque HTTP errors are not.

Browser security policies still apply

CORS, MIME, CORP, COEP, CSP, SRI constrain fonts, scripts, styles, workers. SW is not a bypass.

Event bridge is not free

Page dispatches rf:* directly; SW uses client.postMessage() → page runtime → DOM CustomEvent. Design for loss, ordering, multi-client, debugging complexity.

Solution candidates

Option A: enhance current approach

Extend DOM Observer — listen for <img>, video, source error, swap src/srcset.

Pros: Low cost, no SW lifecycle, reuses hook bus/SRI/DOM attr logic.

Cons: No CSS @font-face, url(), @import; awkward for srcset/picture.

Fit: Low-cost explicit DOM elements only.

Option B: Hybrid SW

New SW fetch layer; keep adapter ownership. In the original rollout plan, the first shipped phase was: SW for image, font, media, CSS subresources, optional style; Observer/Webpack/Vite/SystemJS keep scripts and top-level DOM errors.

Pros: Fills biggest gaps without breaking script semantics; avoids double retry on same Webpack/Vite failure.

Cons: Requires ownership + event bridge; SW artifact, registration, scope, kill switch, E2E.

Fit: The library's currently available opt-in path for expanding resource coverage while keeping existing Webpack/Vite behavior stable.

Option C: SW-first

SW owns script, style, image, font, media, optional fetch; page runtime mostly register + bridge.

Pros: Unified network layer; max coverage on controlled pages.

Cons: Not strict superset — first visit, SRI attrs, opaque, Vite rejection, Webpack CSS promise, module cache still need page logic. Disabling adapters regresses edge cases.

Fit: Research or constrained environments (same-origin proxy, no SRI conflict, controlled CORS/MIME, accept reload).

Option D: full layered architecture

Four layers:

  1. Build-time manifest: type, URL, fallback, SRI, ownership
  2. Page runtime: SW register, kill switch, event bridge, adapters, first-screen fallback
  3. SW: fetch retry/fallback for non-script + CSS subresources
  4. ScriptSequencer (opt-in): sync classic script strict ordering

Pros: Most complete; manifest reduces SW guessing.

Cons: Longest timeline; new build artifacts.

Fit: Post–Hybrid SW MVP evolution rather than the first shipped phase.

Cost and risk

This section preserves the cost/risk comparison from before Hybrid SW shipped. Use “Current implementation status” above for present-day behavior; the remaining risks mainly concern expanding SW ownership and future ScriptSequencer work.

ApproachScopeTestsRelease risk
Enhance Observercore observer, types, docsVitest + minimal PlaywrightLow; limited benefit
Hybrid SWcore SW, plugins, bridge, E2EVitest + Playwright for SW lifecycle, fonts, opaqueMedium; opt-in safe; ownership bugs = double retry
SW-firstnearly everythingHighest — script, dynamic import, CSS promise, SRI, first load, SW updateHigh — misleading "SW replaces all"
Full layeredmanifest + ScriptSequencerLargest; split into phasesScope risk — ship Hybrid MVP first

Historical roadmap (superseded by the shipped opt-in implementation)

The phase plan below is kept as historical planning rationale from before Hybrid SW shipped; it is not current work. The current state is the one described above in “Current implementation status”: Hybrid SW already ships as an opt-in feature with manifest preload, emitted SW assets, page registration/bridge wiring, and fetch-layer fallback for non-script resources / CSS subresources.

Historically, the rollout plan looked like this:

  • Phase 1: design doc + spikes (font CORS, opaque, SRI, first load, builder semantics) before heavy implementation
  • Phase 2: Hybrid SW MVP — opt-in; image, font, media, CSS url(), CSS @import; scripts stay on existing adapters
  • Phase 3: Manifest — Vite/Webpack emit type/URL map; SW decides from manifest; record page-owned resources to avoid duplicates
  • Phase 4: Evaluate top-level style SW ownership only after proving no Observer duplicate retry / Webpack CSS promise regression
  • Phase 5: ScriptSequencer opt-in if sync classic script ordering is required — build-time queue blocking scripts, serial load with retry/fallback per script

The core outcomes of phases 2–3 are already shipped in the current version. Phases 4–5 should be read as possible future evolution, not as present-day implementation status.

Regression / validation checklist (with historical spike context)

This checklist started as the pre-ship minimum spike plan. It is kept here both as historical rationale and as current regression / validation guidance. It does not mean Hybrid SW is still waiting to be implemented.

  1. First-visit control: log whether first-screen script/link/img/font enter fetch — first visit, refresh, reopen, clients.claim(), skipWaiting
  2. Opaque image: no-cors cross-origin image — normal, 404, DNS fail — can SW distinguish and fallback?
  3. Font: @font-face cross-origin .woff2 with/without CORS on fallback
  4. SRI: script/style with integrity — fallback to matching vs mismatched hash
  5. Vite dynamic import: SW success vs giveup — import() Promise, module map, __RF__.load cache bust still needed? Also verify the current handoff still comes from writeBundle + es-module-lexer + MagicString, not the earlier renderDynamicImport / renderBuiltUrl experiments.
  6. Webpack CSS chunk: async component with separate CSS chunk — is page-side CSS promise patch still required with SW?
  7. Event bridge: SW retry/fallback/success/error → postMessagerf:* — order, loss, multi-tab
  8. Kill switch: confirm that __RF_DISABLE__, query flags, and cookies only affect the current page runtime installation path; they do not automatically unregister or take control over an already registered SW. If SW behavior must change, that needs separate SW registration / update / configuration handling.

Synchronous script execution order

SW helps sync classic scripts on controlled pages when fetch fallback succeeds before parse continues — closer to original order than Observer's post-hoc replaceChild.

SW cannot guarantee order when:

  • First visit script never entered SW
  • All URLs fail — parse may continue after error
  • Post-fetch SRI/MIME/CORS/CSP failure
  • Subsequent scripts already ran — SW cannot roll back side effects

Sync script strict ordering is not SW MVP. ScriptSequencer is the reliable path — can coexist with SW as separate opt-in.

Decision

Current architecture: Hybrid SW opt-in, not SW-first.

Not for less work — SW-first cannot cross first-control, SRI attrs, opaque responses, security policy, and builder runtime semantics. Keeping adapters protects solved script/builder problems; SW focuses on fetch-layer resources it handles best.

Medium term: full layered architecture — manifest, SW coverage expansion, page runtime for script semantics + event bridge, ScriptSequencer for sync classic scripts. Matches the library goal: zero mental overhead with explicit semantics.