
Next.js io
If you've enabled Cache Components in a Next.js 16 project, you've probably run into a build error that seems to come out of nowhere: a component that reads new Date() or Math.random() suddenly forces an entire route into fully dynamic rendering, or fails the build outright with a complaint about missing a Suspense boundary. io() is the function Next.js gives you to fix that, and understanding why it exists tells you a lot about how Cache Components actually decides what belongs in a prerendered "static shell" and what doesn't.
The problem: synchronous values have no natural suspension point
Cache Components works by prerendering as much of a route as it safely can at build time (or on first request) and streaming in the rest. To do that, it needs a signal for "this part of the output can differ between requests, don't bake it into the static output." Normally that signal is free: if you await fetch(...) or query a database, the await itself is the suspension point. React sees the pending promise, and Next.js knows everything downstream of it has to wait for a real request.
The trouble starts with values that are dynamic in the sense that matters (they differ every time you call them) but synchronous in the sense that matters to React: new Date(), Math.random(), crypto.randomUUID(), or a synchronous driver like node:sqlite. None of these return a promise, so there's nothing for Cache Components to suspend on. Left alone, Next.js has two ways to interpret this ambiguity, and neither is obviously wrong: capture the value once during prerendering and serve it to everyone (which is what "use cache" does), or refuse to prerender that part of the tree at all and push it into a Suspense fallback for every request. io() exists specifically so you get to pick the second behavior explicitly, without having to fake an await on something that was never actually asynchronous.
Basic usage in a Server Component
The pattern is always the same: call await io() immediately before you read the synchronous, non-deterministic value, and wrap the component that does so in <Suspense>.
// app/page.tsx
import { Suspense } from "react";
import { io } from "next/cache";
export default function Page() {
return (
<Suspense fallback={<p>Loading...</p>}>
<CurrentTime />
</Suspense>
);
}
async function CurrentTime() {
await io();
return <p>{new Date().toISOString()}</p>;
}
During prerendering, await io() suspends, so React falls back to the <p>Loading...</p> placeholder and ships that in the static shell. On an actual request — whether that's SSR for a fully dynamic route, or the streamed-in part of a Partial Prerendering route — io() resolves immediately and the real timestamp renders. If you deleted the io() call and the Suspense boundary, Next.js would either bake a single frozen timestamp into the static shell (wrong, if you wanted a live clock) or fail the build complaining that a Server Component read a dynamic value outside of a request scope.
Basic usage in a Client Component
Client Components go through the same prerendering pass during SSR, so they need the same treatment — just via React's use() hook instead of a bare await, since you can't put await directly in component body code on the client:
// app/components.tsx
"use client";
import { use } from "react";
import { io } from "next/cache";
export function CurrentTime() {
use(io());
return <div>{Date.now()}</div>;
}
This is easy to forget, because it's tempting to assume "Client Component" means "runs in the browser, so server-side prerendering rules don't apply." They do — the component still gets server-rendered once as part of SSR/prerendering, and Date.now() read at that point would otherwise get frozen into the HTML that ships before hydration.
When you don't need io()
io() is only necessary when nothing else in the component already gives Cache Components a suspension point. You don't need it if:
- You're already reading a request-time API.
cookies()andheaders()are themselves suspension points — Next.js knows a call to either one means "this needs a real request," so it excludes that code from the static shell automatically. - You're awaiting a
fetchcall or an async database query, and the component is already wrapped in<Suspense>. Theawaiton the actual I/O is the suspension point; there's nothing forio()to add.
Reaching for io() in those cases isn't harmful, but it's redundant — you'd effectively be adding a second suspension point next to one that already exists.
How io() differs from connection()
Next.js has another function, connection(), that looks similar on the surface — both exclude the code after them from the static shell. The difference is what they wait for.
connection() stays suspended until an actual user navigation reaches the server. That's a much stronger guarantee (you genuinely know a real request is in flight), but it comes at a cost: because it blocks on a real navigation, it also blocks prefetching — a prefetch request isn't a full navigation, so code behind connection() never resolves during one.
io() suspends the same way any other asynchronous call does. It doesn't require a full navigation to resolve — it resolves as soon as it's called in any non-prerender context, including during a prefetch. That means code after io() can still be wrapped in "use cache" and served from a prefetched, cached response. The practical rule: default to io() for "this is a synchronous, non-deterministic read," and reach for connection() only in the rarer case where you specifically need to guarantee you're handling a real, complete user request rather than a prefetch.
io() and "use cache" interact, not conflict
It's worth being explicit about what happens if you wrap a component using io() inside a "use cache" boundary instead of leaving it dynamic — because this is the other valid choice, not a mistake.
// app/page.tsx
async function CurrentTime() {
"use cache";
await io();
return <p>{new Date().toISOString()}</p>;
}
Inside a cached scope, io() is a no-op — it resolves immediately, the value gets captured once, and that captured value is what's served (and revalidated according to whatever cacheLife applies) until the cache entry expires. This is exactly the behavior described for "use cache" scopes in general: io() doesn't override caching, it just steps out of the way when caching is already handling the "when does this value get computed" question.
This gives you a clean mental model for any synchronous, non-deterministic read:
| Where the read happens | What you do | Result |
|---|---|---|
Inside "use cache" | Nothing extra needed (or call io(), it's a no-op) | Value captured once, served from cache |
| Outside any cache, want it live per-request | await io() + <Suspense> | Value computed fresh on every real request |
| Need to guarantee a full navigation, not a prefetch | connection() instead | Suspends until a real navigation lands |
Already reading cookies()/headers()/an awaited fetch | Nothing extra needed | That call is already the suspension point |
Common patterns
Percentage-based feature flags or A/B bucketing. If you're bucketing users with Math.random() and want a genuinely fresh roll per visit rather than one frozen value baked into the static shell for everyone, io() plus Suspense is the correct primitive — this is a case where "use cache" would silently give every visitor the same bucket, which is rarely what you want.
Request IDs for logging/tracing. crypto.randomUUID() used to tag a log line or error report needs to be fresh per request, not baked into a shared static shell. Same pattern applies.
Synchronous embedded databases. node:sqlite and similar synchronous drivers don't return promises the way something like Postgres client libraries do, so there's no natural await for Cache Components to hook into. io() gives you an explicit suspension point without having to wrap a synchronous call in an artificial Promise.resolve() just to get an await to hang off of.
Common mistakes
Forgetting the Suspense boundary. io() suspends during prerendering — if there's no Suspense boundary above the component calling it, that suspension has nowhere to resolve to, and the build will fail rather than silently doing the wrong thing. This is deliberate: Cache Components would rather error loudly at build time than guess.
Calling io() when a request-time API is already present. If the same component already calls cookies(), there's no need to also call io() — you'd be adding a suspension point next to one that's already there. It's not wrong, just unnecessary noise.
Assuming io() makes data private or uncacheable. It doesn't do either of those things by itself. It only controls whether the prerender captures the value or waits for a request. Whether the resulting output is cached, and for how long, is entirely governed by whether the surrounding scope uses "use cache" and what cacheLife you've set — io() and caching are answering two different questions that happen to interact.
Reference
function io(): Promise<void>;
io() takes no parameters and returns Promise<void>. With Cache Components enabled, awaiting it during prerendering stops the prerender at that point, excluding everything after it from the static output. In every other context — a real request, inside a "use cache" scope, inside generateStaticParams, in the browser, or in an app that hasn't enabled Cache Components at all (including the Pages Router) — it resolves immediately and has no effect.
io() was added in Next.js v16.3.0.
Key Takeaways
io()gives synchronous, non-deterministic reads (Date.now(),Math.random(),crypto.randomUUID(), synchronous DB drivers) an explicit suspension point that Cache Components can use to exclude them from the static shell.- Always pair
await io()(oruse(io())in Client Components) with a<Suspense>boundary — without one, the build fails instead of silently freezing the wrong value. - You don't need
io()if the component already reads a request-time API likecookies()/headers(), or already awaits afetch/database call insideSuspense. - Prefer
io()overconnection()by default —connection()blocks prefetching because it waits for a full navigation, whileio()resolves during prefetches too. io()inside a"use cache"scope is a no-op; caching, notio(), decides whether the captured value is shared and for how long.


