Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 6 min read

Deploy succeeded but the live site still shows old content

What the data says We read 6,205 public posts from builders whose apps broke at or after launch and verified 659 recent cases. Deploy and build problems are the largest group, at 25%. Of the 659, 89 verified cases are

What the data says

We read 6,205 public posts from builders whose apps broke at or after launch and verified 659 recent cases. Deploy and build problems are the largest group, at 25%. Of the 659, 89 verified cases are this pattern: the release is blocked or never goes live.

From the builder's side it looks like this. You push, the pipeline ends in success, you refresh, and the old page is still there. Sometimes there is no deployment at all, or the new frontend is live and calls a route or column that does not exist yet. Sometimes half your users get chunk load errors and the other half are fine.

The common thread is that nothing in the pipeline was checking what production serves.

Reproduce it

Two small mistakes, both common on Next.js with Vercel or any similar host.

The first is a caching rule added for performance that matches everything, pages included:

// next.config.js
module.exports = {
  async headers() {
    return [{
      source: "/:path*",
      headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }],
    }];
  },
};

Every response, including the page that references your script bundles, now tells the browser it is fresh for a year. Ship a change, reload, and the browser does not ask your origin again.

The second is a post-deploy check that cannot fail:

- run: npx vercel deploy --prod > deployment.txt
- run: curl -fsS "$(cat deployment.txt)" > /dev/null

This requests the new deployment's own address. That address answers whether or not your public domain points at it, so the step passes when the alias is stale, when the deployment was held back, or when the page is cached.

Why it happens

Success means the build finished and files uploaded. It does not mean production traffic points at that deployment. Pointing production at a deployment is a separate step, and a check against the deployment's own address cannot see it.

Browsers obey Cache-Control, not your intentions. MDN defines max-age=N as the response remaining fresh until N seconds after it was generated, so a cache can reuse it without contacting your origin. no-cache is not "don't cache": the response may be stored but must be validated with the origin before each reuse. If you mean "don't store", the directive is no-store. And if you send no header at all, MDN notes that a cache is allowed to cache the response heuristically, so state your requirements explicitly.

Hosts can hold a deployment back. On Netlify, a production deploy becomes the published deploy only if auto publishing is on, and locking a deploy stops auto-publishing. A lock you forgot about, or a rollback you did not undo, means the build is fine and the pointer is wrong.

Some failures happen before any build log exists. A deployment can fail on a missing precondition, such as an invalid config file or an ignored build step, and then the error is on the deployment, not in a log you can read. A missing repository webhook has the same effect: nothing starts.

Version skew breaks users with a tab already open. Vercel's documentation describes it as different versions of the application running on client and server. A page loaded before the deploy asks for a script chunk by a name that the new deployment no longer has, and gets a 404.

Order matters. If the frontend deploys before the migration, it calls a column that is not there yet.

Catch it before your users do

The test has to ask the public domain what it is serving, and compare that with the commit you shipped. First, write the commit into every build. Vercel exposes VERCEL_GIT_COMMIT_SHA during builds, and the fallback covers other hosts:

{
  "scripts": {
    "build": "echo ${VERCEL_GIT_COMMIT_SHA:-$(git rev-parse HEAD)} > public/version.txt && next build"
  }
}

Then paste this script into scripts/verify-deploy.mjs. It needs Node 18 or later.

const [site, want] = process.argv.slice(2);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const fail = (m) => { console.error("FAIL:", m); process.exit(1); };

let got = "";
for (let i = 0; i < 30; i++) {
  const res = await fetch(`${site}/version.txt`).catch(() => null);
  if (res?.ok) {
    got = (await res.text()).trim();
    if (got === want) {
      if (!/no-store/.test(res.headers.get("cache-control") ?? ""))
        fail("version.txt is cacheable: send Cache-Control: no-store");
      break;
    }
  }
  await sleep(10_000);
}
if (got !== want) fail(`serving ${got || "nothing"}, expected ${want}`);

const page = await fetch(site);
const cc = page.headers.get("cache-control") ?? "";
const maxAge = Number(/max-age=(\d+)/.exec(cc)?.[1] ?? 0);
if (maxAge > 300 && !/no-cache|no-store/.test(cc))
  fail(`page is fresh for ${maxAge}s without revalidation: "${cc}"`);

const html = await page.text();
const refs = [...html.matchAll(/(?:src|href)="(\/[^"]+\.(?:js|css))"/g)].map((m) => m[1]);
for (const ref of refs) {
  const r = await fetch(site + ref);
  if (!r.ok) fail(`${ref} returned ${r.status}`);
}
console.log(`ok: ${want}, ${refs.length} assets`);

Run it as the last step of the pipeline, against your public domain:

node scripts/verify-deploy.mjs https://example.com "$(git rev-parse HEAD)"

It retries for five minutes, then fails with both values. It also fails if the version file is cacheable, if the page is fresh for more than five minutes without revalidation, or if any script or stylesheet the page references returns something other than 200. It only reads the home page, so add the routes that matter to you.

A test you have never seen fail proves nothing. Run it once against the previous commit, which must fail:

node scripts/verify-deploy.mjs https://example.com "$(git rev-parse HEAD~1)"

For the second class of problem, push an empty commit with git commit --allow-empty -m "probe" and confirm a new deployment appears listing that commit. If nothing appears, open the repository's webhook settings and look at recent deliveries before touching your build.

The fix

1. Remove the catch-all cache rule and mark the version file. Pages keep the default or a short lifetime. Only hashed filenames get a year.

// next.config.js
module.exports = {
  async headers() {
    return [
      { source: "/version.txt", headers: [{ key: "Cache-Control", value: "no-store" }] },
    ];
  },
};

For other hosts, the rule is the same: public, max-age=31536000, immutable on /assets/app.3f9a1c.js, and no-cache on the page that references it, so every load revalidates and picks up the new filenames.

2. Install from the lockfile. The npm documentation says npm ci requires an existing package-lock.json or npm-shrinkwrap.json, exits with an error instead of updating the lock if it disagrees with package.json, and never writes to either. A build that installs what you tested cannot quietly pull a new major version.

npm ci

3. Run migrations as a pipeline step before the app deploys. Make the deploy depend on it, and keep each migration additive so the previous release still works against the new schema. With Prisma that step is npx prisma migrate deploy, run from the pipeline and not from a laptop.

4. Protect open tabs. Vercel's Skew Protection is available on Pro and Enterprise. With a supported framework it adds the deployment id to framework-managed requests, including static assets, so an open page keeps loading assets from its own deployment. The default maximum age is one day from deployment creation. It does not pin your own fetch() calls from client components; you pass the id yourself for those.

5. Rehearse a rollback once, while nothing is on fire. Roll back, run the script with the previous commit and see it pass, then roll forward and run it with the new one. On Netlify, lock the deploy so the next Git push does not publish over it.

vercel rollback <previous-deployment-url-or-id>

Checklist

  • version.txt on the public domain equals git rev-parse HEAD for the commit you shipped.
  • The version file is served with no-store, and no page has a long max-age.
  • Hashed assets have a long lifetime; the page that references them revalidates.
  • Every script and stylesheet the page references returns 200.
  • The install step is npm ci.
  • Migrations run in the pipeline, before the app deploys, and are additive.
  • The verify script has failed once on purpose.
  • A rollback and a roll-forward have each been tried, and the marker changed both times.

For a static frontend, Gemmein's publish reports the address and version only after the address has answered, or reports a failure with the step, the file and line when known, and the reason.

Which check has told you a deploy was live while production was still serving the previous commit?

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.