Track static-asset 404s per release

Group asset 404s by URL and deploy SHA. Catch stale CDN caches and missing hashed bundles before users see a blank page.

Published

difficulty intermediate · time to value 2 minutes · tools used 2

Just say this

Are any static assets 404ing more than expected this week? Group by URL and by deploy SHA so I can see if a release broke a cache key.

Why this matters

Hashed-filename CDN deploys are an under-appreciated source of silent breakage. Webpack/Vite/Turbopack rename app.js to app-a3f29b1.js on every build. If a user has the OLD index.html cached but you’ve already invalidated the OLD bundle URL, they get a 404 — and most apps render a blank page with no thrown error.

Sentry catches thrown exceptions, not 404s on script tags. Cloudflare logs the 404 but you’d have to push those into a log warehouse and query them. Nobody does this until a customer complains “your site is broken on my work laptop.”

Agentry logs the asset 404 as an analytics or log event, joins it with your deploy timeline, and tells you “deploy a3f29b1 is generating 50% of the 404s for stale app-9c3a210.js — your CDN edge in EU has stale TTLs.”

What you get

  • Top 10 asset URLs returning 404s in the last 7 days
  • For each: which deploy invalidated them and how many distinct users are still requesting them
  • A diagnosis (stale edge cache vs missing build vs typo in source)

Walk through it

You

Are any static assets 404ing more than expected this week? Group by URL and deploy SHA.

Agent

Let me check recent deploys for context, then query asset 404s.

agentry_list_deploys { project_id: "default", limit: 5 }
{
  "deploys": [
    { "sha": "9c3a210", "received_at": 1747353600, "message": "feat: dashboard v2" },
    { "sha": "a3f29b1", "received_at": 1747180000, "message": "fix: pagination" },
    { "sha": "b1e229c", "received_at": 1746920000, "message": "feat: pricing page" }
  ]
}
agentry_analytics_query {
  project_id: "default",
  hogql: "SELECT properties.url AS url, count() AS cnt, uniq(distinct_id) AS users FROM events WHERE event = 'asset_404' AND timestamp >= now() - INTERVAL 7 DAY GROUP BY url ORDER BY cnt DESC LIMIT 10"
}
{
  "rows": [
    { "url": "/assets/app-a3f29b1.js", "cnt": 1284, "users": 412 },
    { "url": "/assets/vendor-a3f29b1.js", "cnt": 1180, "users": 411 },
    { "url": "/assets/logo-old.png", "cnt": 87, "users": 38 },
    { "url": "/assets/app-b1e229c.js", "cnt": 22, "users": 12 }
  ]
}
Agent

The pattern is clear. Deploy 9c3a210 (today) invalidated the a3f29b1 bundle, but 412 users are still requesting app-a3f29b1.js and getting 404s. That means either:

  1. Their index.html is cached at the edge with stale <script src> references, OR
  2. You’re serving index.html with Cache-Control: max-age=3600 somewhere

The logo-old.png is a small separate issue — 38 users, probably a forgotten reference in an email template or social share image.

Recommended fix: serve index.html with Cache-Control: no-cache, must-revalidate and bump CDN edge TTL on the old bundles to 24h instead of immediate purge. Want me to draft a Cloudflare Page Rules config?

The output

Asset 404s (7d), grouped by URL × deploy

URL                                COUNT   USERS   DEPLOY INVALIDATED
/assets/app-a3f29b1.js             1,284     412   9c3a210 (today)
/assets/vendor-a3f29b1.js          1,180     411   9c3a210 (today)
/assets/logo-old.png                  87      38   (not a build asset)
/assets/app-b1e229c.js                22      12   a3f29b1 (2d ago)

Diagnosis:
  ~411 users hold a stale index.html pointing at the previous bundle.
  Their next page-load JS fails to download → blank screen.
  This is your top user-facing bug right now.

Suggested fix:
  1. Set `Cache-Control: no-cache, must-revalidate` on index.html (immediate)
  2. Keep old bundle URLs alive for 48h after deploy (CDN side)
  3. Add a service-worker check that auto-reloads on bundle 404

Setting it up

You need some signal when an asset fails to load. Three options, in order of effort:

Option A — Cheap browser hook (catches scripts only):

window.addEventListener("error", (ev) => {
  // Script tag 404s fire `error` events on the element, not on window
  if (ev.target && (ev.target as HTMLElement).tagName === "SCRIPT") {
    fetch(`https://api.agentry.sh/v1/analytics/${PROJECT_ID}/`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${AGENTRY_DSN}`,
        "Content-Type": "application/json",
        "User-Agent": "myapp-web/1.0",  // REQUIRED — Cloudflare 403s default UAs
      },
      body: JSON.stringify({
        event: "asset_404",
        distinct_id: visitorId,
        properties: {
          url: (ev.target as HTMLScriptElement).src,
          page: window.location.href,
        },
      }),
    });
  }
}, true); // capture phase — required for resource errors

Option B — fetch wrapper for SPA bundles loaded via dynamic import:

const originalFetch = window.fetch;
window.fetch = async (input, init) => {
  const res = await originalFetch(input, init);
  if (res.status === 404 && typeof input === "string" && input.startsWith("/assets/")) {
    fetch(`https://api.agentry.sh/v1/analytics/${PROJECT_ID}/`, {
      method: "POST",
      headers: { "Authorization": `Bearer ${AGENTRY_DSN}`, "Content-Type": "application/json", "User-Agent": "myapp-web/1.0" },
      body: JSON.stringify({
        event: "asset_404",
        distinct_id: visitorId,
        properties: { url: input, page: window.location.href },
      }),
    });
  }
  return res;
};

Option C — pipe CDN logs (Cloudflare Logpush, Fastly Real-Time): more setup but catches 100% including images and CSS. Send each 404 line as an asset_404 event with url and referrer.

Variations

  • “Show me asset 404s in EU vs US — is one edge stale and the other fine?”
  • “For the top stale bundle, draft a Cloudflare rule that aliases it to the new one for 24h.”
  • “Find image 404s — these usually point to broken email or OG-image references.”
  • “Daily cron: alert me if any new bundle-URL pattern starts 404ing post-deploy.” (uses a Routine)

Try this recipe in your own agent.

Paste the prompt above into your agent. It'll set up Agentry against your data, then run the recipe and show you the real output.

Install https://agentry.sh/agentry.md and set it up