The Deploy That Broke Our Web Vitals
A Real Experience Score that dropped from 90 to 50 in a week wasn’t a bug in the code — it was a bug in the release habit.
I opened Vercel Speed Insights expecting nothing in particular and found a Real Experience Score chart that looked like a small crime scene: a smooth curve near 90 for the first few days, then a cliff down to 50, sitting there for two days before starting to climb back out. First Contentful Paint and Largest Contentful Paint both sitting at 4.1 seconds — solidly in Google's "poor" band. Time to First Byte at 1.94 seconds, same bucket.
My first instinct was the obvious one: something I shipped broke something. I went looking for the bug. There wasn't one.
Reading the shape of the drop, not just the number
The aggregate score is a blunt instrument, but the per-route breakdown underneath it told a much more specific story. The worst offender was /learn/[stack]/[page], sitting at 49 — "Poor". Right behind it, /dashboard at 53. Meanwhile /, /quiz/[stack] and /auth/signup were all sitting at 92–99, "Great", completely untouched.
That split matters. If the regression were in shared layout, a global script, or a font, every route would have moved together. It didn't. Whatever was happening was specific to a route shape — and the two routes that suffered had one thing in common that the three unaffected ones didn't: /learn/[stack]/[page] and /dashboard both do real server-side work per request. The home page, the quiz landing, and the signup page are effectively static.
The cache that resets itself on every deploy
/learn/[stack]/[page] isn't dynamic in the expensive sense — it's ISR, revalidating once a day:
export const revalidate = 86400 // 24hThe detail that's easy to forget: ISR's cache doesn't just expire on its own clock. A fresh production build invalidates the whole thing, immediately, regardless of when it last revalidated. Every deploy is effectively a cache-clear for every statically-generated page in the app. The next visitor to any of those pages doesn't get the cached HTML — they get a cold, on-demand server render, DB query and all, while the page quietly re-populates the cache behind them.
That week had four production deploys in six days. Four cache resets, stacked on top of each other before traffic had a chance to fully re-warm the previous one.
A visit to /learn/[stack]/[page], right after a deploy vs. once warm
Why a handful of slow visits was enough to sink the score
Here's the part that made the dip look worse than the underlying reality: /learn/[stack]/[page] isn't a high-traffic route. The window that produced the "Poor" 49 score had 12 visits total. Real Experience Score is a percentile-based real-user metric, not a synthetic benchmark — it's built entirely from whoever actually showed up. With a sample that small, two or three cold, post-deploy renders landing back to back is enough to drag the whole week's number into the red. The same cold render against a route getting thousands of daily visits would barely register in the aggregate.
/dashboard is a different mechanism entirely — it's force-dynamic, no ISR cache to lose, so a deploy doesn't touch it the same way. Its 53 is closer to a standing cost: Clerk auth plus a Neon query on every single load, already behind a 30-second in-memory user cache and a request-scoped dedupe for the folder fetch. That number didn't spike from the deploys — it was just already the honest price of a fully dynamic, authenticated route, sitting there the whole time under a healthier-looking average.
The fact that the score climbed back toward 90 on its own, with no code change in between, is the strongest evidence for this story. Bugs don't self-heal. Caches re-warm.
What changes going forward
Nothing in the code needed fixing — there was no incorrect query, no missing index, no regression to roll back. The actual fix is a release habit: stop shipping every small change to production the moment it's ready, and batch deploys instead, aiming for roughly once a week rather than once a day. Fewer deploys means fewer full cache resets, which means fewer windows where a low-traffic page's real-user score is at the mercy of whoever happens to load it first.
- →A per-route breakdown beats an aggregate score for diagnosis — three untouched routes next to two bad ones ruled out "global regression" immediately.
- →ISR revalidation windows are not the only thing that clears an ISR cache. A production build does too, on every route, every time.
- →Real-user metrics on a low-traffic route are noisy by construction — a handful of cold hits can swing the whole sample.
- →A metric that recovers with no code change is telling you the cause was transient, not structural. Chase that signal before touching anything.
- →Deploy cadence is a performance lever, not just a shipping-speed one — and it costs nothing to pull.