Next.js Cache Components: Render-Time Synchronous I/O Constraints#
Next.js cacheComponents (the stable form of experimental.ppr, which was folded into a hard-deprecated config error in Next.js 16.3.5) bakes a static shell during prerendering and rejects any nondeterministic value — Math.random(), new Date(), live network reads — encountered synchronously during that render.
The apps/docs app does not yet enable cacheComponents, but three categories of render-time synchronous I/O already cause cache or hydration problems under its constraints, and two have been fixed.
1. npm Download Data — Rate-Limited Build-Time Fetches#
Problem. api.npmjs.org allows ~40 requests per minute per IP. A single docs build sends ~67 unique npm URLs in quick succession, exhausting the window and freezing refused responses into the static prerender. Next.js 16.3.5's patch-fetch.js additionally forces a foreground refetch during static generation when a cache entry is stale, so a refused refetch renders as missing data rather than serving the prior stale entry.
Fix (PR #7858). Four npm-heavy surfaces export dynamic = "force-dynamic", moving all npm reads to request time:
| Surface | File |
|---|---|
/traction | apps/docs/app/(home)/traction/page.tsx |
/packages | apps/docs/app/(home)/packages/page.tsx |
/traction.png | apps/docs/app/traction.png/route.tsx |
/traction-dark.png | apps/docs/app/traction-dark.png/route.tsx |
Pages with only a handful of npm reads (/oss, home page) remain static — their 8 reads fit within the rate window once the heavy surfaces are excluded from the build.
Under force-dynamic, an explicit next: { revalidate } option on each fetch keeps the data cache active (stale-while-revalidate at request time). The traction image routes additionally set Cache-Control: stale-while-revalidate=86400.
No retries on 429 (PR #7859). npmGetJson makes a single attempt. A refused response logs the status and returns null with no retry, because the npm rate limiter blocks the entire minute — a short-delay retry always lands inside the same blocked window.
2. Nondeterministic Thread IDs — generateId() / Math.random()#
Problem. When cacheComponents is active, any synchronous Math.random() call in a Client Component fails with:
Next.js encountered the unstable value Math.random() in a Client Component
generateId() uses nanoid/non-secure's customAlphabet over a 62-character set, producing 7-character strings. It relies on Math.random() and is called synchronously during render in two places:
RemoteThreadListThreadListRuntimeCoreconstructor — callsswitchToNewThread()synchronously before any effect fires, reachingseedNewThread().RemoteThreadListtap-clientuseStateinitializer — callsseedNewThread(EMPTY_LIST)directly during first render.
seedNewThread() produces a local thread ID with format __LOCALID_<generateId()> as a placeholder until the user sends the first message.
Proposed fix (tracked in issue #8683, not yet merged): pass a React useId()-generated value as the initial draft thread ID instead of calling generateId() during render. useId() is deterministic across server and client renders. generateId() would be retained for all subsequent thread seeds.
3. Clock-Dependent Render Output — HeatGraph Default End Date#
Problem. computeGrid() defaults the grid's end date to normalizeDate(new Date()) when no end prop is passed.
The Root component invokes computeGrid() inside a useMemo during render, so when end is omitted the clock read happens at render time — including during server prerendering. If server prerender and client hydration happen on different local dates, the two renders produce different grid ranges and can cause hydration mismatches or stale prerendered output that no longer reflects the current day.
Mitigation: always pass an explicit end (and optionally start) prop to <HeatGraph.Root> to get a deterministic date range independent of server time.
Key Files & References#
| File / Link | Role |
|---|---|
packages/core/src/utils/id.ts | generateId() — nanoid/non-secure, nondeterministic |
packages/heat-graph/src/utils/grid.ts | computeGrid() — default end = new Date() |
packages/heat-graph/src/components/Root.tsx | Root — calls computeGrid in useMemo at render time |
apps/docs/lib/npm.ts | npmGetJson — single-attempt, no-retry npm fetch |
packages/core/src/runtimes/remote-thread-list/remote-thread-state.ts | seedNewThread() — produces __LOCALID_<generateId()> |
| PR #7858 | force-dynamic fix for npm build-time fetches |
| PR #7859 | Remove 429 retry logic from npmGetJson |
| Issue #8683 | Open: cacheComponents prerender failure from generateId() during render |
| KB: SSR-Safe Thread ID Generation | Deep-dive on thread ID seeding and useId() fix |