Serving Local Fallback Bundles

Permalink to "Serving Local Fallback Bundles"

Part of Graceful Fallback Strategies, this page covers shipping a same-origin copy of every third-party bundle so that a blocked or unreachable CDN response degrades into a working page instead of a blank one.

Quick Reference

Permalink to "Quick Reference"
Piece Value Effect
Vendored path public/assets/vendor/<pkg>-<version>.min.js Version-locked same-origin copy
Digest algorithm sha384 One value covers both URLs
Failure hook error event on the element Fires on 404, DNS failure and digest mismatch
Attributes on the CDN tag integrity, crossorigin="anonymous" Both required for a cross-origin check
Attributes on the local tag integrity, crossorigin="anonymous" Same-origin needs neither, both are harmless
Order control script.async = false Injected scripts execute in insertion order
CI gate re-hash local file and CDN response Non-zero exit on divergence

The rule that makes the whole pattern work: one digest, two URLs, one version. If any of those three becomes two, the fallback is dead weight.

The mental model

Permalink to "The mental model"

An integrity check is a hard stop. When the bytes that arrive do not hash to a value listed in the integrity attribute, the browser does not warn and continue — it discards the response, treats the fetch as a network error, and never executes the script. From the page’s point of view the library simply does not exist, and every line of application code that assumed it would be there throws. A CDN edge that serves a stale minified build, a captive portal that injects an interstitial, a corporate proxy that rewrites JavaScript, a regional outage: all of them land in exactly the same place.

A local fallback bundle turns that hard stop into a detour. You ship a byte-identical copy of the same artifact from your own origin, and you wire a second attempt to the error event that the failed element already fires. The second attempt hits a URL that is under your control, was deployed alongside the HTML that references it, and cannot be intercepted by anything that is not already able to intercept the document itself. The two copies share a single digest, so the fallback is not a weakening of the check — the same verification runs against the same expected bytes, just from a different host.

The engineering problem is not the swap. It is keeping the two copies identical for the lifetime of the deployment. A digest that only matches the CDN copy means the fallback never loads, and you will not find out until the day you need it. Everything below exists to make divergence a build failure rather than an outage.

Vendoring one artifact into two locations The npm tarball installed into node_modules is the single source; the vendor step records the pinned CDN URL and writes a same-origin copy into the assets directory, and a SHA-384 comparison asserts that both are the same bytes. npm tarball [email protected] npm ci vendor step scripts/vendor.mjs pinned CDN URL cdn.jsdelivr.net same-origin copy /assets/vendor/ sha384 equal or build fails

Note that the tarball in node_modules is the source for both branches. Public package mirrors serve the file exactly as it was published, so the copy you already have on disk after npm ci is the same object the CDN will hand to a browser. Vendoring from the installed package rather than from an HTTP download keeps the build reproducible offline and removes a network dependency from the release path — the CDN is then only ever checked, never trusted as a source.

Canonical example: vendor, load, and fall back

Permalink to "Canonical example: vendor, load, and fall back"

Three files: a vendor step that writes the local copy and the manifest, a loader that swaps on error, and the markup that calls it.

The vendor step reads the pinned version out of the lockfile-installed package, copies the artifact, and records one digest per entry:

// scripts/vendor.mjs — run: node scripts/vendor.mjs
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'node:fs';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);

const packages = [
  { name: 'lodash', file: 'lodash.min.js' },
  { name: 'htmx.org', file: 'dist/htmx.min.js' },
];

mkdirSync('public/assets/vendor', { recursive: true });

const manifest = packages.map(({ name, file }) => {
  const version = require(`${name}/package.json`).version;
  const source = require.resolve(`${name}/${file}`);
  const basename = `${name.replace('/', '-')}-${version}.min.js`;
  const local = `public/assets/vendor/${basename}`;

  copyFileSync(source, local);

  const digest = createHash('sha384').update(readFileSync(local)).digest('base64');

  return {
    name,
    version,
    integrity: `sha384-${digest}`,
    cdn: `https://cdn.jsdelivr.net/npm/${name}@${version}/${file}`,
    local: `/assets/vendor/${basename}`,
  };
});

writeFileSync('vendor.manifest.json', JSON.stringify({ packages: manifest }, null, 2));
console.log(`vendored ${manifest.length} package(s)`);

The manifest is the only thing the templates read, so a URL, a version and a digest can never disagree in the rendered HTML:

{
  "packages": [
    {
      "name": "lodash",
      "version": "4.17.21",
      "integrity": "sha384-QSHg0AzITA2lj8NwC8OlldRvVaedW7w0kzHqrPCYB+/r2/fLnuAQFbSbcG/JVq/Z",
      "cdn": "https://cdn.jsdelivr.net/npm/[email protected]/lodash.min.js",
      "local": "/assets/vendor/lodash-4.17.21.min.js"
    }
  ]
}

The loader takes one manifest entry, tries the CDN, and retries the same digest against the local URL when the element reports an error. It resolves a promise so that dependants have something to wait on:

// public/assets/loader.js
window.loadWithFallback = function loadWithFallback({ cdn, local, integrity }) {
  return new Promise((resolve, reject) => {
    function attempt(src, isFallback) {
      const el = document.createElement('script');
      el.src = src;
      el.integrity = integrity;
      el.crossOrigin = 'anonymous';
      el.async = false; // keep insertion order across injected scripts
      el.addEventListener('load', () => resolve(src), { once: true });
      el.addEventListener('error', () => {
        el.remove();
        if (isFallback) {
          reject(new Error(`both copies failed: ${cdn} and ${local}`));
        } else {
          attempt(local, true);
        }
      }, { once: true });
      document.head.appendChild(el);
    }
    attempt(cdn, false);
  });
};

Everything that depends on the library starts from the resolved promise, which is what makes the pattern safe for dependants:

<script src="/assets/loader.js" defer></script>
<script defer>
  window.addEventListener('DOMContentLoaded', async () => {
    const manifest = await fetch('/vendor.manifest.json').then((r) => r.json());
    const [lodash, htmx] = manifest.packages;

    await window.loadWithFallback(lodash);
    await window.loadWithFallback(htmx); // starts only once lodash has executed
    document.dispatchEvent(new CustomEvent('vendor:ready'));
  });
</script>
Fallback request sequence The browser requests the bundle from the CDN, the response fails its SHA-384 check and is discarded, an error event fires on the element, and the loader then requests the same-origin copy which passes the identical check. browser CDN edge your origin GET bundle (mode: cors) 200 with altered bytes digest mismatch error event fires GET /assets/vendor/lodash-4.17.21.min.js 200, same sha384, script executes promise resolves

The async = false assignment is the ordering control. A script element created with createElement has its force-async flag set by default, which means it executes whenever it finishes downloading; clearing the flag puts it back into the ordered queue with other injected scripts that did the same. That is enough for scripts appended in the same tick, but it does not help when the second load only begins after an error, which is why the example also chains on the promise. Awaiting each entry costs a round trip of serialisation and is the honest price of a dependency chain — parallelise independent bundles with Promise.all, and only serialise the pairs that truly depend on each other.

Version-locking is what stops the digest from drifting. The vendored filename embeds the exact version that was installed, so upgrading the package writes a new file next to the old one rather than mutating the file the previous release referenced, and the regenerated manifest carries a new digest alongside a new CDN URL. Because the filename changes on every upgrade, the vendored directory can be served with a long immutable cache lifetime, and a rollback that redeploys yesterday’s HTML still finds yesterday’s bytes at yesterday’s path. Deleting superseded copies is a housekeeping task for a later release, not something to do in the same commit as the upgrade.

Variants

Permalink to "Variants"

Markup-only swap for one blocking script

Permalink to "Markup-only swap for one blocking script"

If the bundle must run during parsing and you would rather not ship a loader, put the handler on the tag itself. This is the smallest correct version of the pattern:

<script
  src="https://cdn.jsdelivr.net/npm/[email protected]/lodash.min.js"
  integrity="sha384-QSHg0AzITA2lj8NwC8OlldRvVaedW7w0kzHqrPCYB+/r2/fLnuAQFbSbcG/JVq/Z"
  crossorigin="anonymous"
  onerror="this.onerror=null;var s=document.createElement('script');s.src='/assets/vendor/lodash-4.17.21.min.js';s.integrity=this.integrity;s.crossOrigin='anonymous';s.async=false;document.head.appendChild(s);"></script>

The inline handler needs a matching CSP allowance; on a policy built from Generating Per-Request CSP Nonces an inline event attribute is blocked outright, so the external loader is the better fit there. The mechanics of the event itself are covered in Handling SRI Failures with onerror Handlers.

Stylesheets

Permalink to "Stylesheets"

<link rel="stylesheet"> fires the same error event, and the swap is simpler because no execution order is involved:

<link rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/[email protected]/dist/ui.min.css"
  integrity="sha384-CW/4kvqKmG9JjSRYl7ETeYN9LzrZuvKDyf3Lt5h355HNj4lLLBY1vbfFocd3xheu"
  crossorigin="anonymous"
  onerror="this.onerror=null;this.href='/assets/vendor/some-ui-3.2.0.min.css';">

Reassigning href on the existing element restarts the fetch and keeps the stylesheet’s position in the cascade, which appending a new <link> to the head would not.

Skip the CDN entirely

Permalink to "Skip the CDN entirely"

The strongest version of this pattern is to stop using the remote URL at first position and serve the vendored copy to everyone. You keep the integrity attribute for deploy-time assurance, lose the third-party runtime dependency completely, and pay the bandwidth from your own edge. That trade is worked through in Self-Hosting Third-Party Scripts; a local fallback is the middle position between that and trusting a single remote host.

Fallback strategy comparison A three-by-three matrix comparing no fallback, a second CDN mirror and a same-origin local copy across page availability, bytes added to the deploy, and the risk of the fallback digest drifting. criterion no fallback second CDN local copy availability page breaks on any edge failure survives one host, not a blocked network works whenever the document loads bytes shipped none none copy in every deploy artifact digest drift one URL, one digest two hosts may transform differently contained by a CI digest gate

Read the matrix as a cost curve rather than a ranking. A second remote mirror, the approach in SRI Fallback with Multiple CDN Sources, adds no deploy weight but doubles the number of hosts whose transformations can invalidate your digest, and it does nothing when the failure mode is a network that blocks third-party domains wholesale. The local copy inverts both properties: it costs storage on every release and it removes the external variable entirely.

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • Omitting crossorigin on the CDN tag silently disables the check. Without it the browser makes a no-CORS request, gets back an opaque response, and cannot read the bytes to hash them — the resource is blocked outright with an integrity error even though the file is correct. Both URLs in these examples carry crossorigin="anonymous"; it is mandatory on the cross-origin one and merely uniform on the same-origin one. The full rules are in How CORS and crossorigin Affect SRI.

  • document.write is not available for the swap. The classic window.jQuery || document.write(...) idiom only works from a parser-blocking script, because the parser must still be open for the written markup to be inserted in place. An async or defer tag, and anything injected from JavaScript, fires its error event after parsing has finished; calling document.write there implicitly opens a fresh document and wipes the page you were trying to rescue.

  • A CDN that transforms the file breaks the shared digest. Requesting a minified filename that the published package does not contain makes some mirrors generate one on the fly, and the generated bytes will not match your vendored copy. Always reference a path that exists inside the tarball, with the version pinned exactly — never a range, a latest tag, or a directory redirect.

  • The fallback request is a second round trip on an already-slow connection. The visitor pays the CDN timeout before the local request even starts, so a fallback improves availability, not latency. If the third-party host is slow rather than broken, nothing here helps; that is an argument for serving locally by default.

  • A stale local copy fails closed, but only for the fallback path. If the vendored file drifts and the CDN still works, every visitor is fine and the fallback is quietly dead. Nothing in the browser will tell you. Only the CI gate below catches this, which is why the gate is not optional.

Verification Steps

Permalink to "Verification Steps"

1. Confirm both copies hash to the manifest value

Permalink to "1. Confirm both copies hash to the manifest value"
openssl dgst -sha384 -binary public/assets/vendor/lodash-4.17.21.min.js | openssl base64 -A
curl -sL https://cdn.jsdelivr.net/npm/[email protected]/lodash.min.js | openssl dgst -sha384 -binary | openssl base64 -A

Both commands must print the same base64 string, and it must equal the manifest’s integrity value with the sha384- prefix removed. Digest generation on the command line is covered in Generating SRI Hashes with OpenSSL and shasum.

2. Gate the pair in CI

Permalink to "2. Gate the pair in CI"
// scripts/check-vendor.mjs — run: node scripts/check-vendor.mjs
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';

const digest = (buf) => `sha384-${createHash('sha384').update(buf).digest('base64')}`;
const { packages } = JSON.parse(readFileSync('vendor.manifest.json', 'utf8'));

let failed = 0;

for (const pkg of packages) {
  const localDigest = digest(readFileSync(`public${pkg.local}`));
  const res = await fetch(pkg.cdn);
  if (!res.ok) {
    console.error(`FAIL ${pkg.name}: CDN returned ${res.status}`);
    failed++;
    continue;
  }
  const cdnDigest = digest(Buffer.from(await res.arrayBuffer()));

  if (localDigest !== pkg.integrity || cdnDigest !== pkg.integrity) {
    console.error(`FAIL ${pkg.name}@${pkg.version}`);
    console.error(`  manifest: ${pkg.integrity}`);
    console.error(`  local:    ${localDigest}`);
    console.error(`  cdn:      ${cdnDigest}`);
    failed++;
  } else {
    console.log(`OK   ${pkg.name}@${pkg.version}`);
  }
}

process.exit(failed > 0 ? 1 : 0);

Expected output on a healthy build:

OK   [email protected]
OK   [email protected]

Wire it in next to the rest of the release checks:

# .github/workflows/build.yml
- name: Verify vendored fallbacks
  run: |
    node scripts/vendor.mjs
    node scripts/check-vendor.mjs

Treat a non-zero exit the same way you would treat any other hash drift; the escalation patterns are in Failing CI on SRI Hash Drift.

3. Exercise the fallback in a browser

Permalink to "3. Exercise the fallback in a browser"

Open DevTools, add a request-blocking pattern for cdn.jsdelivr.net/* in the Network panel, and reload. The console must show the block, in the shape Chrome uses:

Failed to load resource: net::ERR_BLOCKED_BY_CLIENT

then the Network panel must show a follow-up request to /assets/vendor/lodash-4.17.21.min.js with status 200, and the page must behave normally. Repeat with a deliberately corrupted integrity value to confirm the digest path takes the same route; the message that identifies that case is described in Debugging SRI Hash Mismatch Errors.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Does the local fallback copy need its own integrity attribute?

It does not need one, because a same-origin response is already under your control, but adding it costs nothing and turns a bad deploy into a visible block instead of silent execution of the wrong bytes. Reuse the identical value on both URLs. If the two files ever diverge, one of the two requests will fail loudly rather than shipping unverified code.

Why does the onerror handler fire at all on an integrity mismatch?

When the digest of a fetched resource does not match any value in the integrity attribute, the browser treats the fetch as a network error. The element does not execute and fires an error event, which is the same event you would get from a DNS failure or a 404. That single hook covers CDN outages, tampering, and truncated responses alike.

Can I use document.write to load the fallback synchronously?

Only from a parser-blocking script, which rules it out for anything marked async or defer and for scripts injected from JavaScript. After the parser has finished, document.write implicitly opens a new document and erases the page. Chain a promise-based loader instead and start dependants when the first load resolves.

How much extra weight does a local fallback add?

Zero bytes for visitors, because the fallback file is only requested after the CDN request fails. The cost is storage and deploy size: every vendored bundle is copied into your build output and uploaded on each release. For a handful of libraries this is a few hundred kilobytes of artifact, not of page weight.

What keeps the local copy from drifting out of sync with the CDN?

A version-locked filename plus a CI job that re-hashes both copies on every build. The vendored file carries the package version in its name, the manifest carries one digest, and the gate downloads the pinned CDN URL and compares. Any divergence fails the build before a fallback that would never load reaches production.

Permalink to "Related"

Related Articles

Handling SRI Failures with onerror Handlers
SRI Fallback with Multiple CDN Sources
Graceful Fallback Strategies Core SRI Fundamentals & Browse…