DEV Community

Ahmed
Ahmed

Posted on Originally published at research.gemmein.com

Deploy succeeded but production is broken: chunk 404s, stale builds

We read 4,704 public posts from builders whose apps broke at or after launch and verified 538 recent cases. Deploy and build is the largest category at 23%. This episode covers one deploy pattern we have 22 verified reports of: the pipeline says success and production is wrong.

From the builder's side it looks like this. The deploy is green and the app works on their machine. In production a stylesheet is missing, or the console shows 404s for JavaScript chunks, or an API route that answers instantly in local development ends in a 504. Sometimes an alias keeps serving an older build, or pushes to the production branch stop starting deploys at all. The host's log ends in success in each of these cases, so that is not where anyone looks.

The causes differ. This article reproduces the one you can trigger in five minutes, a tab that was open during a deploy, and then gives you two tests to run after every deploy.

Reproduce it

A Vite project with one lazily loaded module. Add "type": "module" to package.json.

<!-- index.html -->
<button id="go">Settings</button>
<pre id="out"></pre>
<script type="module" src="/src/main.js"></script>
Enter fullscreen mode Exit fullscreen mode
// src/main.js
document.querySelector('#go').addEventListener('click', async () => {
  const { render } = await import('./settings.js')
  render(document.querySelector('#out'))
})

// src/settings.js
export const render = (el) => { el.textContent = 'settings v1' }
Enter fullscreen mode Exit fullscreen mode

Treat live/ as the host. A deploy removes the previous assets and copies the new build in.

npm i -D vite
mkdir live
npx vite build && cp -r dist/. live/            # deploy v1
python3 -m http.server 4173 -d live &           # the host
# open http://localhost:4173 and leave that tab alone
echo "export const render = (el) => { el.textContent = 'settings v2' }" > src/settings.js
npx vite build && rm -rf live/assets && cp -r dist/. live/   # deploy v2
# now click the button in the old tab
Enter fullscreen mode Exit fullscreen mode

The click does nothing. The network panel shows a 404 for /assets/settings-<hash>.js, and Chrome logs Failed to fetch dynamically imported module. A fresh tab works. That asymmetry is why this one is so hard to reproduce from a bug report.

Why it happens

Vite puts a content hash in each chunk's file name. The tab loaded HTML and an entry script that name settings-<hash>.js as it was at load time. The import() does not run until the click, so the tab asks for that exact file later. The new deploy has new hashes and the old files are gone, so the request returns 404 and the import rejects. The new chunks exist on the server. The old ones, which the open tab wants, do not.

The Vite docs describe this case and tell you to serve the HTML with Cache-Control: no-cache, so browsers do not keep referencing old assets, and to listen for the vite:preloadError event, which carries the original import error as event.payload.

The other shapes of green-but-wrong in our reports have different mechanisms:

  • Production is its own build. The host runs a clean install in its own container, with its own Node.js version, package manager and cache. Without a committed lockfile, ^4.0.0 can resolve to a newer release than the one on your machine. A framework upgrade can also move the build output, and the host uploads whatever is in the configured output directory.
  • A stale build cache. On Vercel the cache key includes the project, framework preset, root directory, Node.js version, package manager and Git branch. Change one of those and the output can differ from a clean local build.
  • Functions waiting on something unreachable. A missing environment variable, a database that refuses the host's network, and a code path that never sends a response all look like a hang from outside, then a 504.
  • Deploy automation switched off. After a Vercel Instant Rollback, auto-assignment of production domains is turned off, so new pushes to the production branch do not replace the rolled-back deployment. A missing repository webhook, an Ignored Build Step script that exits 0 and git.deploymentEnabled set to false stop deploys in the same quiet way.

Catch it before your users do

The first test loads a page, deploys again underneath it, and then uses the page. It runs in the reproduction project above. Replace deploy() with your own build and upload steps, or point it at a staging environment where a second deploy can be triggered from the test.

// skew.spec.js
import { test, expect } from '@playwright/test'
import { execSync } from 'node:child_process'
import { writeFileSync } from 'node:fs'

const deploy = (v) => {
  writeFileSync('src/settings.js', `export const render = (el) => { el.textContent = 'settings ${v}' }\n`)
  execSync('npx vite build && rm -rf live/assets && cp -r dist/. live/')
}

test('a tab open during a deploy still works', async ({ page }) => {
  deploy('v1')
  await page.goto('http://localhost:4173')
  deploy('v2')
  await expect(async () => {
    await page.click('#go')
    await expect(page.locator('#out')).toContainText('settings', { timeout: 1000 })
  }).toPass({ timeout: 10000 })
})
Enter fullscreen mode Exit fullscreen mode
// playwright.config.js
export default {
  webServer: {
    command: 'mkdir -p live && touch live/index.html && python3 -m http.server 4173 -d live',
    url: 'http://localhost:4173',
  },
}
Enter fullscreen mode Exit fullscreen mode
npm i -D @playwright/test && npx playwright install chromium
npx playwright test
Enter fullscreen mode Exit fullscreen mode

As written it fails after 10 seconds, because every click retries a chunk that no longer exists. It passes when the page recovers, either by reloading once or by still being served the old chunk. Both are acceptable outcomes. A dead button is not.

The second test runs after every deploy, against the production domain and not only the deployment's own URL. A rolled-back alias or a disconnected Git hook only shows up on the domain. Run it from your CI on the push, not from the host's build: a host that has stopped deploying also stops running your checks. Have /api/health return the commit the build was made from, for example by writing git rev-parse HEAD into the build.

set -e
url=https://your-domain.example
curl -fsS "$url/" -o /tmp/index.html
asset=$(grep -oE '/assets/[^"]+\.js' /tmp/index.html | head -1)
curl -fsS "$url$asset" -o /dev/null
curl -fsS --max-time 10 "$url/api/health" | grep -q "$(git rev-parse HEAD)"
Enter fullscreen mode Exit fullscreen mode

curl -f fails on any status of 400 or above, so a missing stylesheet or chunk fails the script. --max-time 10 turns a function that hangs into a failure instead of a long wait. The last line fails when the live commit is not the one you just pushed, which covers a stale alias and pushes that stopped deploying. Adjust the asset pattern to your framework's output path.

The fix

Reload once when a chunk fails, with a guard so a chunk that is really missing cannot cause a reload loop.

window.addEventListener('vite:preloadError', (event) => {
  const last = Number(sessionStorage.getItem('chunk-reload') || 0)
  if (Date.now() - last < 10000) return
  event.preventDefault()
  sessionStorage.setItem('chunk-reload', String(Date.now()))
  window.location.reload()
})
Enter fullscreen mode Exit fullscreen mode

Then remove the cause where your host lets you:

  • Serve the HTML with Cache-Control: no-cache in your host's headers config.
  • On Vercel, enable Skew Protection. With a supported framework it pins framework-managed requests (static assets, client-side navigations, prefetches) to the deployment that served the page. It supports Next.js, SvelteKit, Qwik, Astro and Nuxt, on Pro and Enterprise plans, and the default maximum age is one day. Full-page navigations are not pinned by default: a hard refresh gets the latest production deployment, and a client that detects the mismatch reloads.
  • Pin the toolchain and install from the lockfile. npm ci exits with an error if package-lock.json and package.json disagree, and never rewrites either file. Netlify reads .nvmrc or .node-version before falling back to its default Node.js version.
node --version > .nvmrc
git add package-lock.json .nvmrc
npm ci
Enter fullscreen mode Exit fullscreen mode
  • If a clean local build is right and production is not, rebuild once without the cache. On Vercel that is vercel --force.
  • Deploy the backend first and keep changes additive. Leave old fields and endpoints working until no deployed client uses them. Make every function return a response on every path, errors included, so a failure is a status code and not a timeout.
  • After an Instant Rollback, use Undo Rollback on the production deployment tile, or promote a deployment. Both turn auto-assignment back on.
vercel promote <deployment-url>
Enter fullscreen mode Exit fullscreen mode

Checklist:

  • The smoke test passes against the production domain straight after a deploy.
  • A page left open before a deploy can still navigate, or reloads once and then works, with no chunk 404s left in the console.
  • A commit pushed to the production branch appears in the host's deployment list and goes live without manual promotion.
  • A clean local build (npm ci, then the host's build command) produces the same file list as the deployed output.

What does your post-deploy check assert beyond a 200 on the home page?

Top comments (0)