Skip to content

Case study — performance

A seven-day-old homepage that every test reported as healthy

Production served a stale homepage for over three days while every automated check reported green. The cause was a cache header, but the reason it went unnoticed was a flaw in how we were measuring. Both are documented here, including the diagnostic that finally separated them.

This is our own website, not a client engagement. Every measurement below was taken from vedixx.co in production and is reproducible with the commands described. We publish it because a worked example on infrastructure we control is more verifiable than a client story we would have to anonymise.

Subject
vedixx.co — our own production site
Stack
Next.js App Router, standalone output
Hosting
Hostinger, LiteSpeed Passenger, CDN
Trigger
Reported: old navigation showing on mobile

The problem

A deployment had shipped a navigation change several days earlier. It was verified at the time: the new links were present, the routes returned 200, the audit passed all checks. The work was considered done.

Then a report arrived that the live site was still showing the previous navigation, and that pages sometimes loaded without styling or with sections missing. The symptoms were intermittent and appeared mostly on mobile, which is the pattern that usually points at caching — and also the pattern that makes caching a lazy diagnosis.

The uncomfortable part was that our own verification had reported success. Something was wrong with the site, and something was also wrong with how we were checking it.

Investigation

The first useful step was to stop trusting the existing checks and compare two requests that differed by exactly one thing: whether they carried a cache-busting query string.

Every audit we had been running appended a cache-buster. That was added earlier for a good reason — an audit had once reported ten false issues that were all stale CDN copies. But applying it everywhere converted a false-positive problem into a false-negative one, which is worse: the tooling reported green while real visitors received a different page.

The clean request and the cache-busted request returned different HTML. That was the whole finding, and it took two commands to establish.

With the stale copy in hand, the second question was why it looked broken rather than merely old. The stale HTML referenced JavaScript chunk filenames from the build it was generated by. Those files had been deleted from the server when the newer build replaced them. The chunks survived only as edge copies, expiring independently — so the page degraded progressively and differently depending on which node answered.

MeasurementObserved
Clean request — Cache-Controls-maxage=31536000 (one year)
Clean request — Age296814s (82.4 hours)
Cache-busted — Cache-Controlmax-age=0, s-maxage=300, swr=86400
Distinct cached vintages observed3 (~175h, ~124h, ~35min)
Chunk referenced by stale HTML404 at origin, 200 at some edges
Stylesheet referenced by stale HTML404 at origin, 200 at edge (age 140s)
Same chunks on current HTML20/20 returned 200

Implementation

  1. 1

    Prove where the header originated instead of assuming

    Three candidates were plausible: Next.js, a CDN rule, or host configuration. Rather than guess, the cache-control override was temporarily removed from next.config.mjs, the site rebuilt, and the header observed directly. Next.js emitted the one-year value itself on every statically prerendered route. The config was then restored from git to guarantee the working tree was byte-identical.

    Control test: / → s-maxage=31536000 With override: / → max-age=0, s-maxage=300

  2. 2

    Rule out middleware and host configuration

    Searched the repository for any other source of a Cache-Control header. No middleware file existed, no .htaccess was present in the repo, and exactly one Cache-Control appeared in the codebase. This mattered because a fix applied to the wrong layer would have appeared to work while the real source remained.

  3. 3

    Verify the override covers every route class

    The header rule uses a negative-lookahead path pattern, and the genuine risk was that it silently failed to match the site root. Every route class was swept against a local production build: pages, sitemap, robots, manifest, a 404, and a hashed static asset.

    / /services/ /resources/ /robots.txt /sitemap.xml /manifest.webmanifest 404 /_next/static/*

  4. 4

    Purge the surviving pre-fix cache entry

    The configuration change stopped new responses being stored with a one-year lifetime, but it could not evict an entry the CDN already believed was fresh until 2027. Only the homepage was affected — every other route already carried the corrected header with an Age of zero.

  5. 5

    Fix the measurement that concealed it

    A deploy-verification script was added that reads a commit SHA served from the site itself and compares it against local HEAD. This turned "did the deploy land?" from an inference drawn from status codes into a confirmation of the running commit.

    exit 0 = up to date · exit 1 = behind · exit 2 = marker unreadable

Technical decisions

Short s-maxage with a long stale-while-revalidate

Why: Keeps the CDN doing useful work while guaranteeing a deploy propagates within minutes rather than months. The revalidation window absorbs traffic spikes without serving genuinely old content.

Rejected: no-store on HTML, which would have removed CDN benefit entirely and pushed every request to origin.

Leave hashed static assets on a one-year immutable cache

Why: Those filenames are content-hashed, so a new build produces new names. They can never serve stale content, and caching them aggressively is correct.

Rejected: Applying the short lifetime uniformly, which would have discarded a genuine performance win to fix a problem those files do not have.

Serve the build commit as a static file rather than an API route

Why: No runtime cost, nothing to maintain, and it swaps atomically with the rest of the public directory. A route would have added code to a system whose value is that it has almost none.

Rejected: An authenticated status endpoint — more code, more surface, for a value that is not sensitive.

Restore the temporarily-modified config from git rather than by hand

Why: A control test is only safe if reverting it is exact. Restoring from version control guarantees the working tree matches what was there before, which hand-editing does not.

Rejected: Manually re-adding the removed block, which risks a subtle difference that would not be noticed until later.

Trade-offs accepted

What we acceptedWhat it cost
The build commit SHA is publicly readable at /BUILD_SHAMinor information disclosure. For a marketing site with no secrets in repository history this is not a meaningful exposure, and an authenticated alternative would be more code for less use.
HTML is revalidated every five minutes rather than cached for longerSlightly more origin traffic than a longer window would produce. Acceptable in exchange for deploys that propagate predictably.
Audits now require checking both cache-busted and clean URLsTwo checks instead of one. The single check was faster and had been reporting the wrong answer for days.

Outcome

The configuration change and the cache purge resolved it. The homepage now serves the current build with the corrected header, and every JavaScript chunk it references resolves at origin rather than surviving on borrowed time at an edge.

The more durable outcome is the verification change. Deploy confirmation is now based on the commit the site reports about itself rather than on inference from status codes — and that distinction had already caused two wrong conclusions before it was fixed.

One structural issue remains open and is logged rather than quietly resolved: the release process deletes the previous build's static assets shortly after a deploy, so HTML cached in a browser or edge during the changeover can still reference files that no longer exist. The window is now minutes rather than days, but it is not zero.

MeasuredBeforeAfter
Homepage Cache-Controls-maxage=31536000max-age=0, s-maxage=300, swr=86400
Homepage Age on clean request296814s (82.4h)0s
Referenced chunks resolving9/1111/11
Distinct HTML vintages served31
Deploy confirmation methodinferred from status codescommit SHA served by the site
Route classes verified for correct headernot verified8 of 8

Lessons learned

Cache-busting every check measures the origin, not the visitor

It was added to eliminate false positives from stale copies, and it worked — while creating false negatives, which are worse. A health check that never requests what a real browser requests is not measuring the thing that matters.

A configuration fix and a cache purge solve different problems

The config change stopped new entries acquiring a one-year lifetime. It could not evict an entry already stored under the old rule. Treating either as sufficient on its own would have left the incident half-resolved.

Deleting old build assets creates a window where cached HTML breaks

Content-hashed filenames make assets safe to cache forever, and that guarantee only holds while the files still exist. Any HTML that outlives its own build becomes a set of requests for deleted files.

Prove the source before changing it

Three layers could plausibly have emitted that header. Temporarily removing the override and observing the default took a few minutes and replaced a confident assumption with evidence. The alternative was fixing the wrong layer and believing the problem was solved.

Status codes do not tell you which build is serving

Every route returned 200 throughout. A healthy site and a current site are different properties, and only one of them was being checked.

Technology used

  • Next.js App Router
  • Next.js standalone output
  • HTTP caching — s-maxage, stale-while-revalidate
  • Hostinger CDN
  • LiteSpeed Passenger
  • GitHub Actions
  • curl / fetch diagnostics
  • Node.js

Related

FAQ

Questions about this work

Because every check appended a cache-busting query string, which bypassed the stored CDN entry and measured the origin instead. The origin was correct throughout. What visitors received was not, and nothing was requesting the page the way a browser does.

Limited spots for new growth partners

Let's Turn Your Traffic Into Revenue.

Book a free 30-minute strategy call. We'll audit your current growth, spot the biggest opportunities, and map a clear plan, no pressure, just value.

Free audit · Custom plan · Clear pricing & timelines

Chat with us