All articles
nuxt

One caret sign, 194 silent 500s: the outage that only broke on the second request

A reader told me my docs site had rendering problems and refreshing led to a 404. My own check returned a clean 200, because I asked once and they asked twice. The cause was two copies of Vue, and 194 server errors had been printing in every build log for two weeks.

Vinayak Kulkarni
5 min read

A reader left a two-line comment on a post announcing new components: "The page has issues rendering. Do a refresh on it and you land on a 404."

I loaded the same URL and got a clean 200. So did the next person I asked. The site had shipped, the deploy was green, the build was green, and my own verification had passed. The comment was three days old by the time I took it seriously, and it was right.

What the site was actually doing

It was not a 404. It was a 500 on every component page, with status codes flapping like a broken traffic light:

/docs                                   200 200 200 200
/docs/components/shimmer-button         404 500 500 500
/docs/components/receipt-printer        500 500 500 500
/docs/text-animations/depth-dive        500 500 500 500

That pattern is the whole story, and I misread it for a while. The 404 in the second row is not a route problem. It is the first request against a cold isolate, where a later lookup failed differently.

I theorised before I read the error

I spent a turn reasoning about Cloudflare Static Assets, custom domains, and route matching. None of it was the cause. The turn the diagnosis actually started was the turn I stopped guessing and pulled the real stack trace out of the worker.

TypeError: Cannot read properties of undefined (reading 'value')
  at DocsFloatingToolbar.vue SSR render

A toolbar component read .value off something undefined during server-side render. The colour-mode composable resolves to useState('color-mode').value, so one line earlier the state key simply was not there.

Here is the part that matters: the defect was not in that file. That file was the first place the damage became visible. It shipped no change that week.

Two copies of Vue

The dependency graph contained two Vues:

vue@3.6.0-rc.8     <- declared by the project
vue@3.5.42         <- what nuxt resolved

nuxt -> vue 3.5.42 | nuxt -> @vue/server-renderer 3.6.0-rc.8

The project pinned vue at ^3.6.0-rc.8. Nuxt declares a caret range on the stable Vue line. A caret on a prerelease can never satisfy a stable range, so the package manager did the correct, literal thing and installed both. The server renderer ended up running against a Vue instance that Nuxt's own runtime did not own.

Frameworks thread request context through module identity. useNuxtApp() looks up a global that the framework's runtime set. When two copies of the runtime exist, that lookup lands in the wrong one, finds a stale context, and returns something whose shape is right and whose contents are wrong. Nothing throws at the plugin boundary. It throws later, deep inside a component render, in a file you did not touch.

That is the tell: SSR breaking in code you did not change.

Why the status codes alternated

Cloudflare reuses isolates. The colour-mode plugin populates the state key, so per isolate:

  • request 1 — the plugin runs, the key is set, the render finds it, 200.
  • requests 2…N — the key reads back undefined, the render throws, 500.

Every fresh isolate served its first request perfectly. So the site was simultaneously working, for anyone arriving cold, and completely broken, for anyone who refreshed. Which is to say: my uptime monitor was green, my manual check was green, my post-deploy verification was green, and a human being opening a link and refreshing was looking at a 500.

It was never Workers-specific

I assumed this was a platform problem for a while. It is not. The same defect reproduced in a plain Node prerender with no Workers, no database, and no network involved.

That was a gift, because it gave me a deterministic oracle: build the site with a route list, then count the failures.

buildprerender [500] Server Error
before the prerelease bump0 / 478 routes
after the prerelease bump194
with the fix0 / 488 routes

It had been printing in my build log for two weeks

194 errors. In every build. In plain text. And the build still exited 0, because the prerender config carried failOnError: false.

So the CI job passed, the deploy went out, and my "the build is green" check passed, because I read the exit code instead of the body of the log. I had the answer on day one. What I did not have was the habit of doubting a green check.

The fix

Two lines, and a dedupe:

-    "vue": "^3.6.0-rc.8",
+    "vue": "^3.5.43",
pnpm dedupe   # collapses the whole @vue/* family to one version

Plus a guard so a dependency bot cannot put a prerelease back:

ignore:
  - dependency-name: vue
    versions: ['>=3.6.0-0']

The check that would have caught it in one second

pnpm why vue

Run it on any project with a framework that relies on module identity to carry request context. If it prints more than one version, stop. You have a time bomb whose fuse is "how many requests has this process served".

Things that will bite you

A stale global context is worse than a missing one. A missing one throws at the plugin boundary, where you can see it. A stale one throws deep inside a render, in an unrelated file, and looks like someone else's bug.

Exit codes are reports about the build, not the build. failOnError: false turned a 194-error build into a green check. The error count is the signal; the exit code is a summary that lied.

Health checks hide this class of bug by construction. A sparse checker never lands twice on the same isolate. Every cold request to a broken site succeeds. You have to make the second request.

Local dev is the wrong environment for this bug. The dev server's module graph is per-request in a way production's is not, so it can look perfect forever.

"It worked when I verified it" is not evidence if your verification pattern differs from real usage. Mine did. I made cold requests. Real users open a page, then refresh.

The uncomfortable summary

This outage is one idea wearing several costumes: every signal you read is a report about the system, never the system itself, and the reports are where the lies live.

The build exit code said 0 while the build body held 194 server errors. The dependency tree said vue, singular, and meant two different copies. My verification said passing, because my pattern differed from real usage. My monitors said green, because they never asked twice.

Each fix was the same move: assert against the real thing exactly once. Read the error count instead of the exit code. Make the second request instead of the first. Enumerate versions instead of trusting a package name. Run the built artifact against the real backend.

And when a failure depends on how many times you have asked, the bug is not in the input. It is in shared state. Fix identity first: which copy, which instance, which context.

grep -c 'Server Error' build.log   # not: echo $?
nuxtvuepnpmssrcloudflare-workersdebuggingdependencies