I built a financial data dashboard for a client, and every time I shipped a deploy, some visitors complained they were still seeing old numbers. Not everyone, just some, and never consistently. A refresh or two would sometimes fix it, then it would go stale again. That pattern always tempts me to blame the cache or a half-finished deploy. I believed it was a caching problem for the better part of a day.
I purged the CDN cache, I redeployed, I checked the Cache-Control headers — all of it looked correct. Yet the stale HTML kept showing up now and then. What finally stopped me guessing was opening the production logs and searching for anything that was not a 200. There it was, tucked between the successful requests:
TypeError: Cannot read properties of undefined (reading 'toFixed')Not a cache problem. SSR was crashing.
Why it disguised itself as a cache issue
The moment SSR throws an exception while rendering a route, Next.js cannot complete rendering that page. What happens next is exactly what confused me: instead of surfacing a raw error to the visitor, Next.js falls back to an older prerender still sitting in the cache. So from the outside, the symptom reads as "stale HTML after a deploy," when the root cause is a new render failing silently. The stale cache is the victim, not the culprit.
That is why it was intermittent. A request that happened to hit the data that triggered the crash would fail and get served the cached version; another request whose data was fine rendered the new page just fine. Two visitors, two outcomes, from the same deploy.
The root cause: the type says number, the runtime says undefined
My number-display components were written with full faith in the types. They looked roughly like this:
function ChangeBadge({ pct }: { pct: number }) {
return <span>{pct.toFixed(1)}%</span>;
}During development pct was always a number, so tsc was happy and so was I. The trouble is that the data flowing into these components comes from an external boundary: a third-party API that occasionally dips, and partial CMS overrides that sometimes blank out a field. At those moments, the value reaching the component is not a number — it is undefined. TypeScript can never catch this because a type at an external boundary is a promise, not a runtime guarantee. Once undefined.toFixed() runs, the exception is thrown, SSR aborts, and the fallback mechanism above serves the stale prerender.
toLocaleString() has the exact same failure. Another component that formatted large numbers with thousands separators crashed in an identical way the instant its value was null or undefined.
The fix
There are two parts. First, be honest about the types. A prop that carries data from an external boundary must not be typed as a plain number, because the runtime does not honor that promise. I widened it to number | null | undefined so TypeScript forces me to handle the empty case:
function ChangeBadge({ pct }: { pct: number | null | undefined }) {
if (pct == null || !Number.isFinite(pct)) return <span>—</span>;
return <span>{pct.toFixed(1)}%</span>;
}The guard pct == null || !Number.isFinite(pct) catches null, undefined, and NaN in one shot, with an em-dash fallback so the UI stays visually sensible instead of exploding. Note the == null is deliberate: it matches both null and undefined.
Second, I did not want to rewrite the same guard in every number component. I extracted a shared helper so the pattern is consistent and nothing gets forgotten:
// lib/format.ts
export function safeFixed(value: number | null | undefined, digits = 1): string {
if (value == null || !Number.isFinite(value)) return "—";
return value.toFixed(digits);
}
export function safePct(value: number | null | undefined, digits = 1): string {
if (value == null || !Number.isFinite(value)) return "—";
return `${value.toFixed(digits)}%`;
}After that, every number component calls safeFixed() or safePct(), and this single point guards every boundary. No more bare .toFixed() sitting in JSX.
How to rule out cache first next time
The diagnostic lesson I carry from this: when production looks "stuck" on old HTML after a deploy, do not immediately trust that it is the cache. A stale cache is often just the symptom of an SSR crash behind it. Before spending time on purges and redeploys, open the production logs and grep specifically for SSR crashes — look for TypeError and the reading '...' pattern. If it is there, that is your cause, and no cache purge will fix it.
Checklist
- A prop whose data comes from a third-party API or CMS should not be typed as a plain
number; widen it tonumber | null | undefined. - Guard before any numeric method:
value == null || !Number.isFinite(value), with a visually safe fallback. - Extract shared
safeFixed()/safePct()helpers so no guard gets forgotten. - If production serves stale HTML after a deploy, check the SSR logs for a crash before blaming the cache.
- Remember: a green
tscdoes not guarantee runtime data honors the type at an external boundary.
