Engineering Diaries · Frontend Infrastructure
§
Frontend InfrastructureBrowser InternalsCORS

Your CDN Cache Didn't Poison Itself: How Deployment Polling Services Silently Break CORS

You added crossorigin='anonymous' to your script tags. You bumped the cache version. You tested in staging. And production still broke. Here is what nobody tells you about prefetch links, browser HTTP cache, and CORS mode transitions.

01 · The Setup

The Setup

Picture a large single-page application. Hundreds of thousands of lines of TypeScript, compiled down to a handful of JavaScript bundles and served from a CDN - say, Cloudflare. The HTML document is thin: a shell that loads these bundles via <script> tags, boots the framework, and hands control over to the client.

This architecture has a companion piece that most teams build eventually: a deployment polling service. It runs inside the browser as part of the application code, polling an endpoint every few minutes to check whether a newer version of the app has been deployed. When it detects a new deploy, it prefetches the new JavaScript bundles in the background by injecting <link rel="prefetch" as="script"> elements into the document head. The goal is simple - when the user eventually refreshes (or is prompted to), the new bundles are already warm in the browser's HTTP cache, making the transition near-instant.

Separately, the frontend platform team wants to add crossorigin="anonymous" to the application's <script> tags. The motivation is observability: without the crossorigin attribute, JavaScript bundles loaded from a CDN origin that differs from the page origin are treated as opaque by the browser. Observability tools - real user monitoring (RUM) SDKs, session replay tools, error trackers - cannot inspect the response body, read meaningful stack traces, or report granular performance timing for opaque resources. Adding crossorigin="anonymous" tells the browser to make a CORS request, and as long as the CDN responds with Access-Control-Allow-Origin: *, the response becomes transparent and fully inspectable.

Both changes seem benign. Both are well-understood. And together, in the presence of a deployment polling service, they create a cache poisoning scenario that is invisible to every standard testing methodology.

02 · The Contract

The CORS + Cache Contract

To understand why this breaks, you need to understand a subtle but critical contract between the browser's HTTP cache and CORS request modes.

When a browser fetches a resource, it does so in one of several request modes. The two relevant ones here are no-cors and cors. A plain <script src="..."> tag without a crossorigin attribute fetches the resource in no-cors mode. The browser does not send an Origin header, and the response is treated as opaque - the script executes, but nothing can inspect the response body or read detailed error information from it. Add crossorigin="anonymous", and the browser switches to cors mode: it sends an Origin header, expects an Access-Control-Allow-Origin header in the response, and the response is fully transparent.

Here is the critical detail: the browser's HTTP cache partitions entries by request mode. A resource cached from a no-cors fetch is not considered a valid cache hit for a subsequent cors fetch of the same URL. When the browser encounters a <script crossorigin="anonymous" src="https://cdn.example.com/bundle.abc123.js"> tag, it checks the HTTP cache for that URL. If it finds an entry but that entry was cached from a no-cors fetch, the browser rejects it. The result: a response with status 0, zero bytes, and an opaque filtered response. The script tag fails to load.

The Mode Mismatch
<!-- Prefetch: fetches in no-cors mode (no crossOrigin attribute) -->
<link rel="prefetch" as="script"
      href="https://cdn.example.com/bundle.v2.def456.js">

<!-- Script tag: fetches in cors mode -->
<script crossorigin="anonymous"
        src="https://cdn.example.com/bundle.v2.def456.js"></script>

<!-- Same URL. Different CORS modes. Cache rejects the match. -->
<!-- Result: status 0, zero bytes, script fails to load. -->

An important asymmetry exists here. A resource cached from a cors fetch can be served to a subsequent no-cors request. The reverse is what fails. CORS-cached responses are strictly more permissive than no-cors-cached ones. This asymmetry becomes essential to the fix.

Why CSS Doesn't Break CSS <link rel="stylesheet"> tags do not use the crossorigin attribute by default. They fetch in no-cors mode regardless, so a prefetched CSS file in no-cors mode matches perfectly. This is why, during such an incident, stylesheets load fine while every JavaScript bundle fails.
03 · The Polling Service

The Deployment Polling Service

The deployment polling service is a piece of JavaScript that ships as part of the application bundle. It typically works as follows: on a regular interval - say, every five minutes - it fetches a manifest file from the server. This manifest (often named something like sw-assets.json or build-manifest.json) contains the list of hashed bundle filenames for the currently deployed version. The service compares the manifest's contents to its own known version. If the filenames have changed, a new deploy has occurred.

Upon detecting a new deployment, the service creates <link rel="prefetch"> elements for each new JavaScript bundle and appends them to the document head. The browser then fetches these resources at low priority in the background, populating the HTTP cache. When the user eventually refreshes or navigates, the new bundles load from cache instead of making fresh network requests.

Typical Prefetch Implementation
function prefetchBundles(bundleUrls: string[]) {
  bundleUrls.forEach((url) => {
    const link = document.createElement('link');
    link.rel = 'prefetch';
    link.as = 'script';
    link.href = url;
    // No crossOrigin attribute set here!
    document.head.appendChild(link);
  });
}

There are two critical details about this service. First, the manifest typically only includes JavaScript files, not CSS. CSS bundles are not prefetched. Second - and this is the crux of the entire problem - this code runs on the OLD version of the application. The user has the app open, running version 1. When version 2 is deployed, it is version 1's polling service that detects the new deploy and prefetches version 2's bundles. Version 1's code controls the prefetch request mode.

The deployment polling service is an active caching agent operating in the past, creating cache entries for the future. When the CORS mode of script loading changes between versions, the old code poisons the cache for the new code.

If version 1 was deployed without crossorigin="anonymous" on its script tags, its prefetch function also lacks a crossOrigin attribute on the prefetch links. The prefetch happens in no-cors mode. This is consistent within version 1 - no problem there. The problem emerges when version 2 expects cors mode.

04 · The Chain

The Cache Poisoning Chain

Let us walk through the complete sequence of events, step by step.

  1. User has the app open, running v1 code. Everything is normal. Script tags have no crossorigin attribute. The polling service runs every five minutes.
  2. The team deploys v2. This version adds crossorigin="anonymous" to all script tags and bumps the cache version identifier (e.g., v1 to v2 in the URL path), producing entirely new URLs for every bundle.
  3. v1's polling service detects v2. It fetches the manifest, sees new bundle URLs, and begins prefetching them.
  4. v1 prefetches v2 bundles WITHOUT crossOrigin. The prefetch links are created by v1's code, which has no concept of the CORS change. Each <link rel="prefetch"> fires a no-cors request.
  5. Browser caches v2 bundles in no-cors mode. The HTTP cache now contains every v2 JavaScript bundle, keyed with a no-cors request mode.
  6. User refreshes. The server returns v2's HTML, which contains <script crossorigin="anonymous" src="v2-bundle.js"> tags.
  7. Browser checks cache for v2 bundle URLs. It finds entries - but they were cached in no-cors mode. The script tag requires cors mode. Cache hit rejected. Response: status 0, zero bytes.
  8. All JavaScript bundles fail to load. The application cannot boot. The user sees an error screen.
  9. CSS loads fine. Stylesheets were never prefetched (not in the manifest) and load fresh from the CDN with Access-Control-Allow-Origin: *. They render normally, making the error screen look styled but devoid of functionality.
Cache Poisoning Flow
v1 App Running
v2 Deployed
v1 Polling Detects v2
Prefetch v2 (no-cors)
Cache: v2 in no-cors
User Refreshes
Script tag needs cors
Cache Miss / Status 0
App Fails to Boot
Key Insight

This only affects users who had the application open during the deployment window. A user who visits the app for the first time after the deploy gets a clean browser cache - no poisoned entries - and everything works perfectly. This makes the bug feel intermittent and extremely difficult to reproduce.

05 · Testing Blind Spots

Why Testing Didn't Catch It

Every test the team ran passed. Integration tests, end-to-end tests, staging deployments, manual QA - all green. The CORS-specific end-to-end test verified that bundles load correctly with crossorigin="anonymous" on a fresh browser instance. It passed every time. And that is exactly the problem.

This is a cross-build transition bug. It does not exist within any single version of the application. Version 1 works perfectly. Version 2 works perfectly on a clean browser. The bug only manifests at the boundary between versions, when code from v1 interacts with resources from v2 through the HTTP cache. No standard test infrastructure simulates this scenario.

0
Tests Simulate
Cross-Build Transitions
100%
Clean-Browser
Tests Passed
0
Staging Envs with
Pre-Warmed Cache
∞
Hard Refreshes
That "Fix" It

Consider why each layer of testing fails to catch this:

  • Unit tests: Test individual functions in isolation. No browser cache, no network, no cross-version interaction.
  • Integration tests: Spin up a fresh browser context per test. No pre-existing cache entries from a previous version.
  • Staging deployments: Testers visit the staging environment in a clean browser or after a hard refresh. The cache is empty.
  • Manual QA: When a tester sees an error, the first instinct is to hard refresh (Ctrl+Shift+R / Cmd+Shift+R), which bypasses the HTTP cache entirely. The page loads successfully, and the tester concludes the issue was transient.
  • CORS-specific E2E test: Validates that <script crossorigin="anonymous"> loads bundles correctly. On a clean browser, it always does. The test does not pre-warm the cache with no-cors prefetch entries.

The test infrastructure assumes a clean starting state. But in production, users carry state from previous versions in their browser's HTTP cache - and your old code is the one writing to it.

06 · Cache Busting Failure

Why the Cache Version Bump Failed

The team anticipated potential cache issues and took a standard precaution: they bumped the bundle distribution version. The URL structure changed from /assets/v1/bundle.abc123.js to /assets/v2/bundle.def456.js. Every filename hash changed. Every URL is entirely new.

This is supposed to work. Cache-busting via URL changes is the canonical strategy for avoiding stale cache hits. And it does work - for passive cache reuse. When a user refreshes and the browser looks for v2/bundle.def456.js in its cache, it will not find an old entry for v1/bundle.abc123.js and mistakenly serve it. The URLs are completely different. Cache-busting accomplished.

But the deployment polling service is not a passive consumer of cache. It is an active caching agent. It does not rely on stale entries from previous requests - it creates fresh cache entries for the new URLs. When it prefetches v2/bundle.def456.js, it creates a brand-new cache entry for that exact URL. The cache version bump is irrelevant because the entry being created is for the correct, new URL - just with the wrong CORS mode.

The Distinction

Cache version bumps protect against passive cache reuse - preventing old cached responses from being served for new URLs. They do not protect against active cache creation - old running code creating new cache entries with incorrect attributes.

This is a subtle but important distinction. Most cache-busting strategies assume that the only threat is stale data. They do not account for an agent that actively writes to the cache for future URLs, operating under assumptions from a past version of the codebase. The polling service bridges two versions of reality, and the cache version bump only addresses one dimension of the mismatch.

07 · The Fix

The Fix: Two-Phase Deploy

The solution is deceptively simple once you understand the problem. Instead of deploying all changes at once, split the rollout into two phases.

Phase 1: Fix the Prefetch Function

Deploy a change that adds crossOrigin = 'anonymous' to the prefetch links created by the deployment polling service. Do not change the script tags. Do not bump the cache version. Keep everything else identical.

The One-Line Fix (Phase 1)
function prefetchBundles(bundleUrls: string[]) {
  bundleUrls.forEach((url) => {
    const link = document.createElement('link');
    link.rel = 'prefetch';
    link.as = 'script';
    link.href = url;
    link.crossOrigin = 'anonymous'; // <-- This is the fix
    document.head.appendChild(link);
  });
}

This change is zero-risk. Adding crossOrigin to prefetch links causes them to fetch in cors mode. A CORS-cached response can be served to both cors and no-cors consumers. So even though the current script tags still load in no-cors mode (no crossorigin attribute yet), the CORS-cached prefetch entries are compatible. Everything continues to work.

The Asymmetry That Saves Us A resource fetched in cors mode and cached can satisfy both cors and no-cors consumers. A resource fetched in no-cors mode can only satisfy no-cors consumers. Always prefetch in the more permissive mode.

Wait for Phase 1 to fully roll out. Every user must have the updated prefetch code before proceeding.

Phase 2: Deploy the Full CORS Change

Now deploy the rest: add crossorigin="anonymous" to all script tags, bump the cache version, and ship any other related changes. By this point, every user's browser is running Phase 1 code. When their polling service detects Phase 2, it prefetches the new bundles with crossOrigin = 'anonymous'. The cache entries are created in cors mode. When the user refreshes and the new script tags request the bundles in cors mode, the cache entries match. No poisoning. No breakage.

Two-Phase Deploy Strategy
Phase 1: Fix Prefetch
Full Rollout
Phase 2: Add crossorigin
Prefetch uses cors
Cache entries match
App Loads Successfully
08 · Monitoring

Monitoring Blind Spots

When all JavaScript bundles fail to load, something surprising happens: your monitoring goes blind. Most frontend observability tools are themselves JavaScript libraries that ship inside the very bundles that failed to load. If the bundles do not execute, the monitoring SDKs never initialise.

Error tracking (e.g., Sentry): The Sentry SDK is typically initialised in the application's entry point, which lives inside a JavaScript bundle. If that bundle fails to load, the SDK never runs. Sentry sees nothing. No errors are reported. The dashboard stays green while production is on fire.

Real User Monitoring (e.g., Datadog RUM): Same problem. The RUM SDK initialises inside the application code. If the application code cannot load, RUM never starts. Affected users are invisible to your performance dashboards.

Session Replay: Session replay tools record DOM mutations and user interactions via JavaScript. No JavaScript, no replay. There is no recording of what the user saw during the failure.

Your monitoring tools are passengers on the same ship they are supposed to be watching. When the ship sinks before they board, they cannot report the sinking.

The solution is inline telemetry - JavaScript that lives directly in the HTML document, not inside any external bundle. An inline script can detect that bundles failed to load (by checking for expected DOM elements after a timeout, or by listening for error events on script tags) and report the failure to a backend endpoint.

Inline Telemetry (Resilient Monitoring)
<script>
  // This runs even if all external bundles fail to load
  window.addEventListener('error', function(e) {
    if (e.target.tagName === 'SCRIPT') {
      navigator.sendBeacon(
        '/api/telemetry/script-load-failure',
        JSON.stringify({
          src: e.target.src,
          timestamp: Date.now(),
          userAgent: navigator.userAgent
        })
      );
    }
  }, true); // Capture phase to catch errors on script elements
</script>

A subtle additional gotcha: if you try to send this telemetry directly to a third-party observability proxy using Content-Type: application/json, the browser will issue a CORS preflight request (since application/json is not a CORS-safelisted content type). If the proxy does not handle preflight correctly, your telemetry silently fails. Using Content-Type: text/plain avoids the preflight but some proxies reject non-JSON payloads. The most reliable approach is to send telemetry to your own backend endpoint, which then forwards to wherever it needs to go.

09 · The Broader Lesson

The Broader Lesson

This class of bug - cross-build transition failures - is not unique to CORS migrations. It applies to any change where the request characteristics of a resource change between versions, and an active agent from the old version pre-populates the cache for the new version.

The fundamental insight is that deployment polling services create a temporal bridge between code versions. Old code runs in the browser while new code is deployed on the server. Most of the time, this bridge is benign - the old code fetches new resources, and the new code uses them. But when the contract between "how a resource is fetched" and "how a resource is consumed" changes between versions, the bridge becomes a vector for cache poisoning.

Rule of Thumb

Any code that prefetches cross-origin resources must use the same CORS mode as the eventual consumer. If the consumer's CORS mode will change in a future version, the prefetch code must be updated first, in a prior deploy.

Cache-busting strategies based on URL changes (hash-based filenames, version path segments) protect against stale passive cache entries. They do not protect against active cache creation by old running code. The two-phase deploy pattern - update the cache-writing mechanism first, then change the cache-reading expectations - should be standard practice for any change that affects the CORS mode, credential mode, or other fetch metadata of cached resources.

More broadly, this is a reminder that browser behaviour is a specification, not an implementation detail. The HTTP cache's mode-partitioned storage is not a bug - it is correct behaviour per the Fetch specification. The spec explicitly states that a cache entry stored from a request with one mode is not a match for a request with a different mode. Understanding these specifications is not optional for teams that operate large frontend applications with complex deployment pipelines.

Finally, this incident pattern exposes a gap in how we think about frontend testing. We test individual versions exhaustively. We test migrations between database schemas. But we almost never test the interaction between two simultaneously running versions of frontend code - one in the browser, one on the server. As deployment strategies grow more sophisticated (progressive rollouts, A/B deployments, service workers, prefetching), this class of cross-version interaction bug will only become more common.

10 · Checklist

Checklist for CORS Migrations

If you are planning to add crossorigin="anonymous" to your script tags, or change the CORS mode of any CDN-served resource, work through this checklist before deploying:

  1. Audit every code path that creates resource hints. Search your codebase for every place that creates <link rel="prefetch">, <link rel="preload">, <link rel="modulepreload">, or calls fetch() / new Request() for CDN resources. Each one is a potential source of no-cors cache entries.
  2. Check for background services that pre-warm caches. Deployment polling services, service workers with precaching strategies, and build manifest prefetchers are all active caching agents. If any of these exist, they must be updated to use the correct CORS mode before the script tags change.
  3. Deploy in two phases. Phase 1: add crossOrigin to all prefetch/preload mechanisms. Wait for full rollout. Phase 2: add crossorigin="anonymous" to script tags and bump cache version. Never combine these into a single deploy.
  4. Add inline telemetry for total JS failure. Your error screen must have its own monitoring - an inline script that fires a beacon to a backend endpoint when script tags fail. Do not rely on SDKs that load from the same bundles.
  5. Monitor script error events, not just Sentry. A page_load_status-style metric that fires from inline JavaScript is your only reliable signal when all bundles fail. Sentry and RUM go dark in this scenario.
  6. Test the "app open during deploy" scenario. Create an integration test that simulates a user with the old version's cache state, then loads the new version. No clean-browser test covers this. Consider a test that: loads v1, triggers its prefetch mechanism for v2 URLs, then navigates to v2's HTML and asserts all scripts load.
Quick Reference - CORS Cache Compatibility Matrix cors → cors Compatible. cors → no-cors Compatible. no-cors → cors BREAKS. no-cors → no-cors Compatible.

When in doubt, always prefetch in cors mode. It is strictly more permissive. A cors-cached response can serve any consumer. A no-cors-cached response can only serve no-cors consumers.
· · ·

The browser's HTTP cache is a shared, mutable, long-lived data store that your old code writes to and your new code reads from. Treat CORS mode changes to cached resources with the same rigour you would treat a database schema migration: backwards-compatible first, then cut over, never both at once.

Further Reading: Fetch Specification - Request Mode | MDN - crossorigin attribute | MDN - rel="prefetch"

All technical claims in this article reference the WHATWG Fetch Living Standard and Chromium's HTTP cache implementation behaviour as of May 2026. Browser behaviour described has been verified in Chromium-based browsers (Chrome, Edge) and Firefox.