MonkeyLoader All articles
Performance

Dead Weight in the dist Folder: Hunting JavaScript Nobody Loads

MonkeyLoader
Dead Weight in the dist Folder: Hunting JavaScript Nobody Loads

Photo: developer reviewing JavaScript build files on laptop in office, via nihaoma-mandarin.com

Every UK SaaS team has a dist folder with a past. A framework upgrade lands, a checkout step moves, someone deletes a route at 5:55pm before a bank holiday, and the build keeps a handful of hashed JavaScript files that nothing imports any more. They sit on the CDN like unclaimed luggage: not obviously broken, but paid for on every deploy, scanned by security tools, and copied into staging snapshots until someone asks why the release artefact is 14MB heavier than it was in the spring.

At MonkeyLoader we are not against a bit of build archaeology. We are against doing it by vibes. Here is a practical way to find orphaned chunks, stop a missing chunk from becoming a blank screen, and clean up without turning a quiet Tuesday into an incident.

A ghost chunk is a manifest problem first

Orphaned files are rarely a disk problem. They are a disagreement between what your build produced and what your loader can reach. In a modern bundler that means the asset manifest, the runtime chunk map, and the HTML entry points. If a dynamic import is removed but the old hashed file remains, the manifest may no longer mention it. If a framework upgrade changes chunk splitting, the old names can linger in a public directory. If a rushed release copies dist over the top instead of replacing it, you get a museum.

Treat the manifest as the source of truth, then verify it against the files. For webpack, start with stats.json or the generated asset manifest. For Vite, read the manifest.json and follow entry, dynamicImports and imports. For a hand-rolled loader, your own registry is the manifest. If you cannot explain how a file is reached from an entry point, it is a suspect, not automatically rubbish.

Build a three-column inventory before deleting

Make a simple spreadsheet or script output with three columns: file in dist, referenced by manifest or runtime, and route or feature that needs it. The third column is the one people skip. A chunk can be unreferenced by the current manifest but still be fetched by an old service worker, a cached HTML page or a support bookmark. That does not make it immortal, but it changes the cleanup window.

Start with the build output:

find dist -type f -name '*.js' | sed 's|^dist/||' | sort > dist-js.txt

Then export the reachable set from your bundler. In Vite, a small Node script can walk manifest entries and collect file plus imports plus dynamicImports. In webpack, use the stats file and collect chunks where initial or entry is true, then walk children. Do not just grep the HTML. Half your application is probably behind a dynamic import.

Compare the two lists. The difference is your candidate pile. Before you touch it, filter out public assets, service worker files, PDFs, images and anything referenced from CSS. Then sort candidates by last modified date and size. A 4KB chunk from a deleted experiment is noise. A 300KB vendor chunk that only an old admin route imported is worth a conversation.

Put a guard on the loader before the broom

Deleting a chunk that a live route still needs is how you get the classic ChunkLoadError on a Saturday. The guard is not a substitute for correct cleanup, but it turns a hard failure into a recoverable one. Put it around every dynamic import your loader controls.

async function loadChunk(loader) {
  try {
    return await loader();
  } catch (error) {
    const message = String(error && error.message);
    const isChunkError = /ChunkLoadError|Loading chunk|Failed to fetch dynamically imported module/.test(message);
    if (!isChunkError) throw error;
    const key = 'chunk-retry:' + location.pathname;
    if (sessionStorage.getItem(key)) throw error;
    sessionStorage.setItem(key, '1');
    location.reload();
  }
}

Two rules. One retry per page per session, otherwise a genuinely missing chunk becomes a reload loop. And log the failure before reloading, with the chunk URL and route, so your monitoring knows the difference between a stale cache and a bad deploy. If you use a third-party loader, check whether it exposes an onError hook; do not wrap it in a second loader that hides the first one's state.

Clean up in daylight, with a rollback

Safe cleanup is less about the delete command and more about the order of operations. Pick a quiet weekday morning, not Friday afternoon. Freeze deploys to the affected app for the window. Take a full copy of the current dist and manifest, not just a git tag, because the artefact is what the CDN serves.

Run your inventory in dry-run mode and review the candidate list with someone who knows the product routes. Remove the obvious dead files in a branch. Deploy to staging. Click through the routes that matter: sign-in, checkout, account settings, the report nobody remembers, the admin page that only one person uses. Then watch the CDN and error tracker for 404s on JavaScript for at least a full working day. Hashed chunks are cached aggressively, so a stale browser can ask for an old file long after your deploy.

When you are confident, delete in small batches. Keep a 30-day archive of removed files in object storage with the same paths, so a rollback is a copy rather than a rebuild. Add a 404 alert for .js requests that maps back to your app. If the alert fires for a chunk you just removed, you have found a live dependency, not a reason to panic.

Make the next spring clean boring

The real win is not one heroic cleanup. It is making orphan detection part of the release routine. Add a CI step that compares the manifest reachable set to the dist JavaScript files and fails only when the orphan count or total orphan bytes crosses a threshold. Keep the threshold generous at first; you want signal, not a build that cries wolf on every vendor split.

Also fix the habits that create ghosts. Replace dist on deploy instead of copying over it. Keep the manifest in the artefact and log its version at runtime. Give dynamic imports a named comment or a route owner. When a feature is removed, remove its entry from the loader registry in the same pull request. That is less exciting than a spring clean, but it is cheaper.

Ghost chunks are not a moral failing. They are what happens when a small UK team ships quickly and the build system keeps the receipts. Audit the manifest, guard the loader, delete in daylight, and put the check in CI. Your CDN bill will not throw a party, but your next framework upgrade will be a lot less haunted.

All Articles

Related Articles

Switch It Off Before They Find It: Five Default Loaders That Fail a UK Accessibility Audit

Switch It Off Before They Find It: Five Default Loaders That Fail a UK Accessibility Audit

The 3am Phone Call You Don't Need: Quietly Measuring Script Failures in the Wild

The 3am Phone Call You Don't Need: Quietly Measuring Script Failures in the Wild

The 6:40 Bell and the 40,000 Parents: A Closure Page That Holds

The 6:40 Bell and the 40,000 Parents: A Closure Page That Holds