You ship an update. The deploy succeeds. Then someone emails: "The site looks broken — no styling, weird layout." You hard-refresh and everything looks fine on your machine. So what happened?

This post explains a real production issue we fixed while building Great Lakes Additive — a Next.js storefront on Google Cloud Run. If you build or operate modern web apps, the pattern applies beyond this stack: cached pages can outlive the assets they reference, and serverless hosting makes that mismatch show up fast.

Who this is for: engineers self-hosting Next.js on GCP, and founders or product owners who want to understand why a deploy can look fine in CI but broken for real users — without needing to read the source code.


The short version

If you are…Here's the headline
Business / productA deploy replaced the server, but some users still received an old copy of the page that pointed at CSS files that no longer exist. Hard refresh fixed it; our fix made that mismatch impossible at the infrastructure level.
EngineeringNext.js ISR/full-route cache persisted HTML across Cloud Run deploys. Hashed _next/static assets changed every build, but GCS-backed cache entries were not scoped by BUILD_ID. We added a custom GCS cacheHandler, build-ID prefixes, client-side deploy detection, and a post-deploy smoke check.

The symptom

After a new release, some users saw:

  • Unstyled HTML (plain browser defaults)
  • Missing fonts and layout
  • A page that looked fine after Ctrl+Shift+R (hard refresh)

The app wasn't down. API calls worked. Navigation worked. But cached HTML was pointing at CSS and JS files from a previous build — files that no longer existed on the new container.

If you've ever seen _next/static/chunks/…css return 404 while the document returns 200, you've met this bug.

For a business, this shows up as "the site is broken" support tickets right after a release — even when monitoring says everything is green.


Why Cloud Run makes this worse

Cloud Run is stateless by design — each container instance has no guarantee of a persistent local disk:

  1. Containers are ephemeral — each deploy replaces the filesystem. Old _next/static hashed assets vanish with the old revision.
  2. Multiple instances — without shared cache storage, each instance builds its own in-memory cache. That's inconsistent and wasteful.
  3. Cold starts — a new instance has no warm cache; without external persistence you rely entirely on whatever was baked into the image.

Next.js aggressively caches rendered pages (static generation, incremental static regeneration or "ISR", fetch cache, image optimization) for performance. That's correct behavior — until the cached HTML outlives the static assets it references.

On a long-lived Node server with a single deployment, this mismatch is rare. On Cloud Run, every deploy is a new filesystem.


What Next.js is caching

For a typical App Router site, Next.js can persist several kinds of cache (via a custom cache handler):

Cache typeWhat it isExamples
Full route cachePre-rendered HTML for pagesMarketing pages, product listings
Fetch cacheResponses from server-side fetch() callsCMS content, API data
Image optimizationResized/compressed imagesnext/image/_next/image outputs
Route handlersCached API-style responsesRSS feeds, webhooks

By default, Next.js stores this on the local filesystem (.next/cache). On Cloud Run, that cache dies with the container — unless you plug in a custom cacheHandler.


The solution: GCS-backed cache handler

We implemented cache-handler.mjs — a custom handler that reads and writes cache entries to Google Cloud Storage, using a bucket already in the project's GCP stack.

High-level flow:

Stored path shape:

gs://bucket/next-cache/{BUILD_ID}/entries/{hash}.value.bin

Why GCS?

  • Shared across all Cloud Run instances — consistent cache hits regardless of which container serves the request.
  • Survives container restartswhen scoped correctly (more on that below).
  • Already in the stack — no new infrastructure if you're on GCP.

Why binary serialization?

Early versions stored cache values as JSON. That breaks Next.js internal structures — Map objects (e.g. segmentData) deserialize as plain objects and silently corrupt cached pages.

The handler uses Node's v8.serialize / v8.deserialize for route cache entries, with validation to reject legacy JSON-shaped entries. Image cache entries store raw buffers separately.

Configuration

In next.config.ts:

typescript
if (isProd) { nextConfig.cacheHandler = require.resolve("./cache-handler.mjs"); nextConfig.cacheMaxMemorySize = 0; // force GCS, not in-memory fallback }

Runtime env vars on Cloud Run:

env
GCS_BUCKET=your-bucket NEXT_CACHE_PREFIX=next-cache GITHUB_SHA=<git commit sha> # used at build time; see BUILD_ID section

The Docker image copies cache-handler.mjs into the standalone output alongside server.js.


The bug we missed the first time: BUILD_ID scoping

The first version of the handler wrote everything under:

gs://bucket/next-cache/entries/…

That worked for sharing cache across instances — but failed across deploys.

Each Next.js build produces new hashed filenames:

html
<link rel="stylesheet" href="/_next/static/chunks/abc123.css" />

After deploy, the new build only has xyz789.css. If GCS still serves old cached HTML referencing abc123.css, you get broken styling.

The fix: prefix every cache key by build ID

javascript
const BUILD_ID = readBuildId(); // from .next/BUILD_ID const CACHE_PREFIX = `next-cache/${BUILD_ID}`;

Each deploy reads and writes only under its own prefix:

next-cache/a1b2c3d4/entries/… ← deploy N next-cache/e5f6g7h8/entries/… ← deploy N+1 (clean slate)

New deploys never read stale HTML from a previous release.

Critical detail: read BUILD_ID from the filesystem

We initially keyed the prefix off process.env.GITHUB_SHA at runtime. That usually matched the build — unless someone deployed without the env var, or it fell back to development. Meanwhile generateBuildId() at build time baked a different ID into asset URLs.

Mismatch = stale cache prefix = broken styles.

The reliable approach:

javascript
function readBuildId() { try { return readFileSync(join(process.cwd(), '.next', 'BUILD_ID'), 'utf8').trim(); } catch { return process.env.GITHUB_SHA?.trim() || 'development'; } }

The cache prefix must match the build embedded in the running container — not a loosely synced environment variable.

generateBuildId uses the same source:

typescript
generateBuildId: async () => { return process.env.GITHUB_SHA || process.env.BUILD_ID || 'development'; },

Browser tabs and long-lived sessions

GCS scoping fixes server-side stale HTML. It doesn't help a user who:

  1. Opened the site before a deploy
  2. Left the tab open for hours
  3. Navigated client-side with old JavaScript still in memory

We added a small client component (BuildIdGuard) that, when the tab regains focus, sends a HEAD request and compares the X-Next-Build-Id response header to the build ID baked into the current bundle. Mismatch → automatic reload.

Production HTML already sends:

Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate

Static assets correctly use immutable long cache — they're content-hashed and safe to cache forever for that build.


Bonus: middleware, Edge, and node:crypto

While hardening caching, we hit a separate production crash:

Failed to load external module node:crypto

Next.js 16 still runs middleware.ts on the Edge runtime. Our bundler pulled cache-handler.mjs (which imports node:crypto and @google-cloud/storage) into the Edge middleware chunk — even though middleware never imported it directly.

Fix: migrate to proxy.ts (Next.js 16's Node.js-default request interceptor). Edge middleware is a poor fit when your server graph touches Node-only APIs like GCS clients or node:v8 serialization.


Operational safeguards

Startup logging

On boot, the handler logs:

[cache-handler] GCS cache enabled: gs://bucket/next-cache/{BUILD_ID}/

If GCS_BUCKET is unset, it warns and falls back to in-memory cache (fine for local dev, not for multi-instance production).

Post-deploy CI check

After every Cloud Run deploy, GitHub Actions runs a smoke script that:

  1. Fetches homepage HTML
  2. Extracts a /_next/static/…css reference
  3. Confirms CSS returns HTTP 200
  4. Logs X-Next-Build-Id and Cache-Control for debugging

This catches "HTML references dead assets" before customers do.

GCS lifecycle

Old build prefixes accumulate. A bucket lifecycle rule deletes objects under next-cache/ after 30 days. Other bucket prefixes (CMS uploads, user files, etc.) are unaffected.

Manual purge if needed:

bash
gcloud storage rm -r "gs://your-bucket/next-cache/**"

Architecture diagram


Takeaways

  1. Treat the filesystem as ephemeral. Anything in .next/cache must be externalized for production serverless.
  2. Scope cache by build ID — never share rendered HTML cache across deploys. Read .next/BUILD_ID, don't guess from env vars alone.
  3. Use binary serialization for Next.js cache values — JSON will corrupt internal structures.
  4. Set cacheMaxMemorySize: 0 when you want GCS to be authoritative, not a silent in-memory fallback per instance.
  5. Verify after deploy — confirm HTML-referenced CSS returns 200 before calling the release done.
  6. Plan for stale browser tabs — server cache fixes aren't enough for long-lived single-page sessions.
  7. Watch Edge vs Node boundaries — custom cache handlers and GCS clients belong on Node, not in middleware bundles.

Stack reference

PieceChoice
FrameworkNext.js 16 (App Router, standalone output)
HostingGoogle Cloud Run
Cache storageGoogle Cloud Storage (custom cacheHandler)
CI/CDGitHub Actions → Artifact Registry → Cloud Run
Build IDGit commit SHA via GITHUB_SHA

Building on Next.js or moving a product to GCP? Get in touch with Cusic Digital — we help teams ship, scale, and productionize custom web platforms.