DNS Failover and Orphaned Fingerprinted Assets

A health check flaps in the middle of a deploy. DNS shifts traffic to the standby provider, that provider pulls from a standby origin that has not finished receiving the new build, and users get HTML referencing app.a1b2c3d4.js from an origin where that file does not exist yet. Every chunk request returns 404, the application never boots, and the CDN dashboards show a healthy green because the HTML was served perfectly.

This is the failure mode fingerprinting cannot protect you from on its own. Content hashing guarantees that a URL and its bytes never disagree; it guarantees nothing about whether the URL exists on the origin the request happened to reach.

The Failure, Step by Step

Cutover inside the deploy window Three lanes on one timeline. The deploy syncs hashed assets to the primary origin and uploads HTML, DNS moves traffic to the standby forty seconds in, and the user receives new HTML whose asset requests 404 until replication to the standby origin completes. A cutover that lands inside the deploy window Deploy DNS User t0 sync to origin A 300 new hashes t0+20 s HTML uploaded purge fan-out starts t0+90 s replication to B done both origins agree t0+40 s health flap traffic moves to B t0+41 s HTML build 2f9c chunk requests 404 t0+90 s assets resolve the gap closes itself The orphan window is exactly the replication lag between the two origins
Fifty seconds of broken application, self-healing, and invisible to every check that only looks at the HTML.

The evidence in a browser is a ChunkLoadError or a bare Failed to fetch dynamically imported module, with 404s on paths that are demonstrably correct. Confirm the diagnosis by asking each origin directly rather than through the CDNs, which will happily cache the 404:

HASH_PATH=/assets/app.a1b2c3d4.js

for origin in origin-a.example.net origin-b.example.net; do
  code=$(curl -s -o /dev/null -w '%{http_code}' -I "https://${origin}${HASH_PATH}")
  printf '%-24s %s\n' "$origin" "$code"
done

# origin-a.example.net     200
# origin-b.example.net     404   <-- the standby has not received this object

A 200 from one origin and a 404 from the other, for a path the current HTML references, is the entire bug. Everything below is about closing the window in which that state is reachable by real traffic.

Replication Lag Is the Orphan Window

If both providers pull from one bucket, this failure cannot happen — which is the strongest argument for the single-asset-origin topology described in the multi-CDN purge orchestration guide. The moment you introduce a second origin for resilience, you also introduce a window in which the two disagree, and its width is your replication lag.

Replication lag window Origin A holds the new hashed files from the start of the deploy. Origin B is missing them until replication completes ninety seconds later, and a failover at forty seconds falls inside that gap. Replication lag is the orphan window Origin A new hashes written at t0 Origin B missing new hashes replicated, parity restored failover at t0+40 s t0 t0+40 s t0+90 s t0+5 min A cutover inside this band serves HTML whose assets do not exist yet
Every second of replication lag is a second in which a failover produces a site that loads its HTML and nothing else.

Three properties of the replication job decide how wide the band is. Direction — a job triggered by the deploy is bounded and predictable; continuous bucket replication is eventually consistent with no upper bound you can quote. Ordering — if HTML replicates before assets, the standby serves new HTML with missing chunks even without a failover, which is the same bug on a timer. Completeness — a replication job that skips objects it considers unchanged will skip nothing here, since every new hash is a new key, but it will happily leave the previous build behind if a lifecycle rule already removed it from the source.

The fix for ordering is the same as for any deploy: assets first, HTML last, everywhere. Replicate /assets/ to the standby and wait for that job to report complete before uploading HTML to either origin. The CI/CD asset pipeline guide sequences a single-origin deploy the same way; a second origin just adds one wait.

Measuring the Lag You Actually Have

Quote a real number rather than the replication service’s marketing figure. Write a uniquely named canary object to the primary at deploy time and poll the standby until it appears:

#!/usr/bin/env bash
# scripts/replication-lag.sh — how long until the standby has a new object
set -euo pipefail

KEY="assets/replication-canary-$(date +%s).txt"
printf 'canary\n' | aws s3 cp - "s3://assets-origin-a/${KEY}" >/dev/null
START=$(date +%s%N)

until curl -sf -o /dev/null -I "https://origin-b.example.net/${KEY}"; do
  sleep 0.5
done

printf 'replication lag: %d ms\n' $(( ($(date +%s%N) - START) / 1000000 ))
aws s3 rm "s3://assets-origin-a/${KEY}" >/dev/null

# replication lag: 47230 ms

Run it hourly and alert on the 99th percentile, not the mean. Replication that averages two seconds and occasionally takes four minutes gives you a four-minute orphan window, and the average tells you nothing about it. The measured p99 is also the correct value for the deploy job’s wait: block on replication for p99 plus a margin before uploading HTML, and the mid-deploy version of this incident stops being reachable.

Retention Windows That Must Span the Failover

The mirror-image failure is a standby that has only the new build. A client holding HTML from ten minutes ago fails over, requests the previous build’s chunks, and the standby returns 404 because its lifecycle rule swept them. Both origins therefore need a retention window that covers every source of stale HTML, and the window must be the longest of them, not the average.

Component Typical value What it forces
Browser HTML max-age 0–60 s Minimum time a client can hold an old document
CDN HTML edge TTL 30–300 s How long a PoP serves the old document after a failed purge
DNS record TTL 60–300 s How long after a cutover a client keeps using the old provider
Resolver over-caching up to 2× the TTL Real observed tail beyond the published DNS TTL
Single-page-app session tail hours to days Sessions that never reload and keep requesting old chunks
Replication lag 30 s–10 min How long the standby lacks the newest hashes

Add the caching layers and you get minutes; add the session tail and you get days. A seven-day retention on hashed asset prefixes covers all of it at negligible storage cost, and it must be configured identically on both origins:

{
  "Rules": [
    {
      "ID": "retain-hashed-assets-7d",
      "Status": "Enabled",
      "Filter": { "Prefix": "assets/" },
      "NoncurrentVersionExpiration": { "NoncurrentDays": 7 },
      "Expiration": { "ExpiredObjectDeleteMarker": true }
    }
  ]
}

Note what this rule does not do: it never expires current objects. Hashed assets are only ever added, so “current” is the whole set and the rule only reaps versions superseded by an accidental overwrite. That is the safe default. A rule that expires objects by age instead — "Expiration": { "Days": 7 } — deletes chunks that are still referenced by any HTML older than a week and is the most common way teams manufacture this incident for themselves. The related reasoning about how long each caching layer can hold a reference is set out in immutable Cache-Control and TTL tuning.

Gate the Cutover on Asset Parity, Not Just Origin Health

Most failover configurations health-check a single path — /healthz or / — from a handful of probe locations. That check passes on an origin that is missing every chunk of the current build, because the HTML is present and the health path is not fingerprinted. A cutover gate needs three conditions, and the parity check is the one nobody has.

Cutover gates Origin health, asset parity, and retention are checked in sequence. All three passing allows the DNS weight shift; any one failing holds the cutover and raises an alert. Three gates before traffic moves Gate 1 — origin health standby answers 200 for a known hash Gate 2 — asset parity every manifest hash exists on both Gate 3 — retention previous build still on the standby Cutover allowed shift DNS weight Hold the cutover alert and fix before retrying fail pass pass
Origin health alone is not a readiness signal. Parity against the manifest is what proves the standby can serve the current build.

Point the health check at a fingerprinted path that changes every deploy, and the first gate starts doing real work. A route that returns the current build’s manifest — or simply a HEAD against the largest hashed chunk — turns a liveness probe into a readiness probe:

# Route 53 health check aimed at a build-specific path, not "/"
resource "aws_route53_health_check" "standby_assets" {
  fqdn              = "origin-b.example.net"
  port              = 443
  type              = "HTTPS_STR_MATCH"
  resource_path     = "/assets/manifest.json"
  search_string     = "2f9c41a7"
  failure_threshold = 2
  request_interval  = 10
}

The search_string is the deploy identifier. When the standby is behind, the string is absent, the check fails, and Route 53 stops steering traffic there — which is the correct behaviour, because a standby that cannot serve the current build is not a standby.

The Pre-Failover Parity Check

Run this before any deliberate cutover, and on a schedule between deploys. It reads the build manifest and asserts that every hashed file it names resolves on both origins.

// scripts/asset-parity.mjs — assert both origins can serve the current build
// Usage: node scripts/asset-parity.mjs dist/manifest.json
import { readFile } from 'node:fs/promises';

const origins = ['https://origin-a.example.net', 'https://origin-b.example.net'];
const manifest = JSON.parse(await readFile(process.argv[2], 'utf8'));

// A Vite/Webpack manifest maps source paths to emitted, hashed filenames.
const files = [...new Set(Object.values(manifest).map((entry) =>
  typeof entry === 'string' ? entry : entry.file
).filter(Boolean))];

async function head(origin, file) {
  try {
    const response = await fetch(`${origin}/${file}`, {
      method: 'HEAD',
      signal: AbortSignal.timeout(5000)
    });
    return response.status;
  } catch {
    return 0;
  }
}

async function pool(items, limit, worker) {
  const results = [];
  let index = 0;
  const runners = Array.from({ length: limit }, async () => {
    while (index < items.length) {
      const i = index;
      index += 1;
      results[i] = await worker(items[i]);
    }
  });
  await Promise.all(runners);
  return results;
}

let missing = 0;
for (const origin of origins) {
  const statuses = await pool(files, 16, (file) => head(origin, file));
  const absent = files.filter((_, i) => statuses[i] !== 200);
  console.log(`${origin}: ${files.length - absent.length}/${files.length} present`);
  for (const file of absent.slice(0, 10)) console.log(`  missing ${file}`);
  missing += absent.length;
}

if (missing > 0) {
  console.error(`parity check failed: ${missing} object(s) missing — do not cut over`);
  process.exit(1);
}
console.log('parity check passed: both origins can serve this build');

The concurrency pool matters at realistic manifest sizes: a build with 300 chunks across two origins is 600 HEAD requests, which takes about four seconds at 16 in flight and over a minute serialised. Keep it fast enough that nobody is tempted to skip it during an incident.

Extend the same check backwards to cover retention by running it against the previous build’s manifest, which you should be keeping as a deploy artefact:

# Current build must be present; previous build must still be present too.
node scripts/asset-parity.mjs dist/manifest.json
node scripts/asset-parity.mjs artifacts/manifest-previous.json

If the second command fails, a lifecycle rule is sweeping assets that live clients still reference — fix the retention policy before touching DNS.

Surviving the Window in the Client

Infrastructure gates shrink the window; they do not close it, because a client can be mid-navigation when DNS moves. Give the application one recovery path for a chunk that genuinely cannot be fetched. A dynamic import that fails is recoverable if you retry once and then reload against whatever HTML the new provider is serving:

// src/lazy.js — retry a failed chunk once, then reload to re-resolve the build
const RELOAD_FLAG = 'chunk-reload-attempted';

export async function lazy(loader) {
  try {
    return await loader();
  } catch (error) {
    // A second attempt handles a transient 404 during a replication gap.
    try {
      await new Promise((resolve) => setTimeout(resolve, 1200));
      return await loader();
    } catch {
      // Reload once — the fresh HTML names hashes the current origin has.
      if (!sessionStorage.getItem(RELOAD_FLAG)) {
        sessionStorage.setItem(RELOAD_FLAG, '1');
        location.reload();
        return new Promise(() => {}); // never resolves; the page is going away
      }
      throw error;
    }
  }
}

The sessionStorage flag is what stops a reload loop when the chunk is missing for a structural reason rather than a transient one — without it, an orphaned asset turns into an infinite refresh, which is a worse incident than the blank screen it was meant to fix. Clear the flag on a successful boot so the next genuine gap gets its own retry.

When to Reconsider

Prefer one asset origin. Almost every problem on this page is caused by the second origin, not solved by it. Object storage with cross-region replication and a global endpoint gives you regional redundancy without two divergent namespaces, and both CDNs pointing at one endpoint makes parity a non-question. Take two origins only when the requirement is surviving the loss of an entire storage provider.

Accept a slower failover in exchange for correctness. A cutover gated on parity is slower than one gated on a ping — usually by seconds, occasionally by the length of a replication job. If your availability target genuinely cannot absorb that, the answer is to shorten replication lag, not to remove the gate.

Do not fail over during a deploy at all. The simplest mitigation is a deploy lock that suppresses automatic cutovers for the duration of a release, with a manual override. It converts a rare, confusing failure into a rare, loud one — and the split-brain reconciliation check already gives you the signal to lift the lock as soon as both providers agree on the build.