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.
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>
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.
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
crossoriginon 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 carrycrossorigin="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.writeis not available for the swap. The classicwindow.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. Anasyncordefertag, and anything injected from JavaScript, fires its error event after parsing has finished; callingdocument.writethere 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
latesttag, 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.
Related
Permalink to "Related"- Implementing Dynamic Script Loaders with Integrity — the general injected-script loader that the fallback loader here is a specialisation of
- Configuring SRI for jsDelivr and unpkg — choosing a pinned CDN path whose bytes will still match a vendored copy months later
- Verifying Deployed Assets Against a Hash Manifest — extending the same manifest comparison from vendored bundles to everything you publish