How an S3 Sync's --delete Flag Turned Every Open Tab Into a ChunkLoadError After Each Deploy
← Back
September 15, 2026CI/CD8 min read

How an S3 Sync's --delete Flag Turned Every Open Tab Into a ChunkLoadError After Each Deploy

Published September 15, 20268 min read

14:32 UTC. Sentry fires an alert group neither of us had seen cross a threshold before: ChunkLoadError, 40 events in three minutes and climbing. Support's queue starts filling with the same complaint in different words: "the site is frozen," "nothing happens when I click."


the setup

storefront-web is a Next.js 14 App Router app. SSR runs on ECS behind an internal load balancer; everything under /_next/static lives in an S3 bucket fronted by CloudFront, uploaded as part of every deploy by a step that had shipped, unquestioned, for over a year.

.github/workflows/deploy.yml, static asset step
- name: Sync static assets to S3
  run: |
    aws s3 sync .next/static s3://storefront-static-prod/_next/static \
      --delete \
      --cache-control "public,max-age=31536000,immutable"

The --delete flag was there for a reason someone had a good argument for once: without it, the bucket only grows, hashed chunk from every build sitting there forever. Six months of daily deploys at a few megabytes of JS each adds up, and nobody wanted to explain a surprise S3 bill in a cost review. So every deploy synced the new build's output and removed anything not in it.

The deploy at 14:28 UTC was about as low-risk as they come: a copy change on the returns policy page, no logic touched. It shipped in four minutes, health checks green, no alerts. The alerts started four minutes after that.


the scramble

First theory: the deploy broke something. On-call rolled it back at 14:37, expecting the error rate to drop the way it always did for a bad release. It didn't. If anything, a second, smaller spike followed the rollback within a minute, which ruled out "the new code is broken" as cleanly as a graph can rule anything out, because the new code was already gone.

Second theory: CloudFront was serving garbage, a cache poisoning issue or a misconfigured behavior returning the wrong content type for JS. The CloudFront console showed normal cache hit ratios and no spike in 5xx from the distribution itself, only from the origin on a specific, narrow set of paths.

Third theory, and the one that wasted the most time: an ad blocker or browser extension pattern, since the reports weren't universal, some users were fine, others stuck. That story falls apart once you actually read the Sentry grouping instead of guessing from support tickets. Every single event carried the exact same chunk filename.

Sentry, grouped issue
ChunkLoadError: Loading chunk 4521 failed.
(error: https://cdn.storefront.example.com/_next/static/chunks/4521-8f2a91cd0e3b4a7f.js)

47 events · affected sessions all opened the site before 14:28 UTC

Not random. Not extension noise. One file, and a sharp cutoff by session start time.


the hunt

A direct request to that URL returned a CloudFront 403, origin-generated, meaning S3 itself didn't have the object anymore, not a permissions or routing issue in front of it.

confirming the object was gone
$ aws s3 ls s3://storefront-static-prod/_next/static/chunks/ | grep 4521-8f2a91cd0e3b4a7f
(no output)

CloudTrail's data events for the bucket answered when and why in one query.

CloudTrail, filtered to DeleteObject on the affected key
14:28:41Z  DeleteObject  chunks/4521-8f2a91cd0e3b4a7f.js  user=github-actions-deploy-role

The deploy at 14:28 hadn't broken anything in the new build. It had deleted a file the previous build was still depending on. The returns-policy copy edit touched a component that, several imports down, changed the content of a shared chunk enough for webpack to give it a new hash. The old hash, 4521-8f2a91cd0e3b4a7f.js, wasn't in the new build's output directory, so --delete did exactly what it was told and removed it.

That alone wouldn't matter for a fresh page load, the new HTML references the new hash. It matters for every tab that was already open. Next.js's App Router doesn't reload the page shell on navigation, it fetches the next route's JS chunk by the hash baked into the build manifest the browser already has in memory. A tab open since before 14:28 still holds the old manifest, and the old manifest still points at 4521-8f2a91cd0e3b4a7f.js. The click works exactly as designed, right up until the fetch for that file returns a 403 instead of a script.

That's also why the rollback made things worse instead of better. Rolling back is just another deploy: a new sync, with its own --delete, against a build whose chunk hashes matched the original pre-14:28 code. It deleted the interim build's chunks, stranding the tabs that had loaded during the four minutes the bad deploy was live, on top of the tabs already stranded from before. Every deploy through this pipeline broke whichever set of open tabs didn't match whatever was currently in the bucket.


the find

Root cause: the static asset sync treated the S3 bucket as a mirror of the current build instead of an accumulating history of recent builds. --delete is correct for keeping a bucket in sync with a source directory. It is the wrong model for an asset store that browsers keep stale references into for as long as a tab stays open, which for an e-commerce site checking out over lunch or across a slow support call can be hours.

Vercel's own deployment model sidesteps this entirely: every deployment gets its own immutable asset namespace, and old ones stay resolvable until they're explicitly pruned well after the fact. Rolling your own static asset pipeline on S3 means rebuilding that guarantee by hand, and this one never had it.


the fix

The sync step lost --delete entirely. New builds only add.

deploy.yml, after
- name: Sync static assets to S3
  run: |
    aws s3 sync .next/static s3://storefront-static-prod/_next/static \
      --cache-control "public,max-age=31536000,immutable"

Pruning still needs to happen, or the original cost concern comes back in six months. It just can't happen on the same clock as a deploy. A separate job runs nightly, keeps every chunk referenced by the last 10 build manifests no matter how old, and only removes anything outside that window past a 48-hour floor, comfortably longer than any real browser tab survives unattended.

scripts/prune-static-assets.py, nightly job
KEEP_MANIFESTS = 10
MIN_AGE_HOURS = 48

referenced = set()
for manifest in recent_manifests(limit=KEEP_MANIFESTS):
    referenced.update(manifest["chunks"])

for obj in list_bucket_objects(BUCKET, prefix="_next/static/chunks/"):
    age_hours = (now() - obj.last_modified).total_seconds() / 3600
    if obj.key not in referenced and age_hours > MIN_AGE_HOURS:
        delete_object(BUCKET, obj.key)

On the client, a global handler now catches a chunk load failure and forces exactly one hard reload, which picks up the current HTML and manifest instead of leaving the user stuck on a dead click.

app/chunk-error-handler.tsx, mounted once in the root layout
'use client';

import { useEffect } from 'react';

export function ChunkErrorHandler() {
  useEffect(() => {
    const handler = (event: ErrorEvent | PromiseRejectionEvent) => {
      const message = 'message' in event ? event.message : String((event as PromiseRejectionEvent).reason);
      if (!/ChunkLoadError|Loading chunk [\d]+ failed/.test(message)) return;

      const key = 'chunk-reload-attempted';
      if (sessionStorage.getItem(key)) return;
      sessionStorage.setItem(key, '1');
      window.location.reload();
    };

    window.addEventListener('error', handler);
    window.addEventListener('unhandledrejection', handler);
    return () => {
      window.removeEventListener('error', handler);
      window.removeEventListener('unhandledrejection', handler);
    };
  }, []);

  return null;
}

The sessionStorage guard matters as much as the reload itself: without it, a genuinely broken chunk (a bad deploy, not a stale one) reloads the page into the same error in an infinite loop instead of failing visibly.


the aftermath

214 ChunkLoadError events in the first 9 minutes, before the fix shipped
~4% Of active sessions at deploy time, estimated from affected session count
2 Deploys it took to notice the rollback wasn't the fix, it was another instance of the bug
0 ChunkLoadError spikes across the 40 deploys since, on the additive sync

Nobody's code was wrong in the sense that usually gets a fix shipped in fifteen minutes. The build was correct, the deploy was healthy, the CDN was doing exactly what it was configured to do. The bug lived in the gap between "what the current build needs" and "what browsers that loaded the previous build still expect," and a flag meant to keep a bucket tidy quietly assumed those were the same set.

  • A static asset store needs to mirror every build an open tab might still reference, not just the latest one. That's a different retention problem than keeping a bucket in sync with a folder, and it needs a different answer.
  • Rollback undoes code. It does not undo a delete that already ran, and running another sync to "fix" a delete just relocates the blast radius to a different set of tabs.
  • Grouped error tracking beats support tickets for finding a sharp boundary. "Some users" sounds like noise; "every event has the same filename and the same session-start cutoff" is a root cause with the investigation half done for you.
  • If you're managing your own CDN origin instead of using a platform that does immutable per-deployment assets for you, you've taken on rebuilding that guarantee yourself, whether or not anyone decided that on purpose.

The nightly prune job is what keeps the bucket from growing forever. The dropped --delete flag is what stopped deploys from being the thing that broke browser tabs in the first place, and it cost nothing more than a slightly bigger S3 bill nobody has actually complained about since.

Share this
← All Posts8 min read