Skip to content
← Journal
8 min readAta Mohammadi

Next.js in 2026: The Flight Wire Format, Cache Components and Tag Invalidation

React Server Components do not send HTML and they do not send JSON. Here is what actually goes over the wire, how Cache Components replaced three separate flags, and the invalidation race that a single-argument `revalidateTag` used to hide.

Two things about React Server Components confuse people persistently, and they are related. The first is what actually travels over the network. The second is how anything gets invalidated once it has been cached in four places at once.

Both have concrete answers, and both changed meaningfully in Next.js 16.

What is on the wire

Open the network tab on an App Router navigation and you will find a response that is neither HTML nor JSON. It looks roughly like this:

0:D{"name":"Page","env":"Server"}
1:I["app/client/Counter.tsx",["chunk-a1b2.js"],"Counter"]
0:["$","div",null,{"className":"shell","children":[["$","h1",null,{"children":"Revenue"}],["$","$L1",null,{"initial":42}],"$L2"]}]
2:["$","p",null,{"children":"Loaded later"}]

That is the Flight format — React's serialisation of a rendered tree. Read line by line:

  • Each line is id:payload, and the stream is chunked. The browser starts reconstructing before the server has finished.
  • I rows are client component references: a module path, the chunks needed to load it, and the export name. The component's code is not in the stream; only a pointer to it, so the client can fetch and hydrate.
  • $L2 is a lazy reference. The tree contains a hole, and chunk 2 arrives later to fill it. This is the entire mechanism behind Suspense streaming: the shell ships immediately with placeholders, and the slow parts land as they resolve.
  • ["$","div",null,{…}] is a serialised React element — type, key, props — where $ marks the array as an element rather than data.

Three consequences follow, and they answer most "why can't I…" questions:

Props must be serialisable. Not JSON-serialisable — Flight handles Date, Map, Set, BigInt, typed arrays and promises — but functions and class instances cannot cross. The exception is a server action, which serialises as a reference, not as code. That is why you can pass a server function to a client component but not an arbitrary callback.

This is not SSR. SSR renders components to an HTML string, once, at request time. Flight is a serialisation of the component tree, produced on the server and reconciled on the client. On navigation there is no HTML at all: the client receives Flight and updates the existing React tree in place, which is why client state survives a server-rendered navigation.

Payload size is a real budget. Every prop you pass to a client component is in that stream. Passing a 200-row array into a client table means shipping 200 rows of Flight on every navigation, in addition to any hydration cost. Push the boundary down the tree and pass identifiers instead of objects.

Cache Components: three flags became one

Next.js has changed its caching defaults three times in three years, which is why nobody is confident about them.

Version Default fetch() Client router cache (dynamic) The mental model
14 force-cache — cached indefinitely 30 s Implicit, and the source of most stale-data confusion
15 no-store — uncached 0 s Explicit opt-in per fetch
16 Uncached, with caching declared structurally Governed by the cache profile Cache units, not cache settings

Next.js 16 introduced cacheComponents, and the important detail is in the version history: it "controls the ppr, useCache, and dynamicIO flags as a single, unified configuration". Three experimental flags that people were enabling in incompatible combinations became one.

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
};

export default nextConfig;

With it on, nothing is cached unless you say so, and you say so by marking a unit:

// app/components/revenue-card.tsx
import { cacheLife, cacheTag } from "next/cache";

export async function RevenueCard({ tenantId }: { tenantId: string }) {
  "use cache";
  cacheLife("hours");
  cacheTag(`tenant:${tenantId}:metrics`);

  const metrics = await fetch(`https://api.internal/revenue/${tenantId}`).then((r) => r.json());

  return <p>Quarterly revenue: {metrics.formattedTotal}</p>;
}

That is partial prerendering, expressed as a component property rather than as a route-level flag: the static shell prerenders, this card is served from cache, and anything not marked is rendered per request and streamed in.

Why you cannot read cookies() inside use cache

This is the constraint everyone hits, and it is not a limitation — it is the definition. A cached unit is keyed by its arguments. If it could read the request, its output would depend on something that is not in the key, and the cache would serve one user's render to another.

So: read request state outside the cached scope and pass it in.

// app/page.tsx — dynamic, per request
import { cookies } from "next/headers";
import { RevenueCard } from "./components/revenue-card";

export default async function Page() {
  const tenantId = (await cookies()).get("tenant")?.value ?? "default";
  return <RevenueCard tenantId={tenantId} />;
}

The card is now cached per tenantId, which is what you meant. When refactoring genuinely is not possible — a compliance constraint, a third-party integration — 'use cache: private' exists for per-user caching, and 'use cache: remote' lets a platform supply a shared cache handler at the cost of a network round trip.

cacheLife has three numbers, not one

Property Meaning
stale How long the client may use a value without asking the server
revalidate How often the server refreshes it; a stale value is served while it does
expire The hard limit — past this, the value is no longer served and the request goes dynamic

Named profiles ("seconds", "minutes", "hours", "days", "max") cover most cases, and you can define your own in next.config.ts. The distinction to internalise is between revalidate and expire: revalidate is when it gets refreshed, expire is when serving it becomes unacceptable. Setting them equal throws away stale-while-revalidate and gives every user past the window a blocking request.

The invalidation race

Here is the failure that produces the bug report "I saved it, and it showed the old value, and then I refreshed and it was right".

A mutation completes. You invalidate the tag. The user's navigation is already in flight, or the client router cache still holds a segment, or a CDN node has a copy that has not been told. The read that races the write wins, and it wins with stale data.

Next.js 16 addresses this by splitting the operation in two, and the split is the most practically useful change in the release.

// app/actions/publish.ts
"use server";

import { revalidateTag, updateTag } from "next/cache";

export async function publishPost(id: string) {
  await db.post.update({ where: { id }, data: { status: "PUBLISHED" } });

  // Read-your-own-writes. Expire immediately; the next request for this tag
  // blocks and fetches fresh. The person who clicked publish sees the result.
  updateTag(`post:${id}`);

  // Everyone else: mark stale, serve the old value, refresh in the background.
  // The second argument is required — the one-argument form is deprecated.
  revalidateTag("post-list", "max");
}

Two different needs, two different primitives:

  • updateTag(tag) — Server Actions only. Expires immediately, so the next read is a blocking refetch. Correct for the person who made the change.
  • revalidateTag(tag, "max") — anywhere on the server. Marks stale with stale-while-revalidate semantics, so nobody waits. Correct for everyone else.

The one-argument revalidateTag(tag) is deprecated precisely because it conflated these: it expired immediately, which meant every reader of a popular tag took a blocking miss at the same moment. Invalidate a tag on a high-traffic page that way and you convert a cache hit into a thundering herd against your database — an availability incident caused by an invalidation call.

Tag design

Tags are the API, so design them like one:

`post:${id}`               // one entity
`post:${id}:comments`      // one relation of one entity
`tenant:${tenantId}:posts` // a tenant-scoped collection
"post-list"                // a global collection

Three rules that save real pain:

  1. Tag both the entity and every collection it appears in. Updating a post must invalidate the post and the list, or the index page lies.
  2. Scope by tenant. A global posts tag in a multi-tenant app means every tenant's cache is destroyed by any tenant's write.
  3. Stay under 256 characters, and keep them mechanically generated. A tag built by string concatenation in three different files will diverge, and a typo'd tag fails silently — nothing invalidates, and nothing tells you.

Activity: the quiet change

One more thing cacheComponents turns on that is easy to miss. Navigation now uses React's <Activity> component: instead of unmounting the route you navigated away from, Next sets it to hidden. State is preserved, effects are cleaned up on hide and recreated on show, and going back restores the previous screen with its form inputs and scroll position intact.

It is a genuine improvement and it will break assumptions. Anything that relied on unmount to close itself — a dropdown, a dialog, a subscription cleaned up in an effect return — now behaves differently, because the component is still mounted. If a modal stays open across a navigation after you enable the flag, that is why.

The short version

  • Flight is a chunked, streamable serialisation of a component tree with holes in it, not HTML and not JSON. What you pass across the client boundary is what you pay for on every navigation.
  • cacheComponents replaced ppr, useCache and dynamicIO with one flag and one model: nothing is cached unless a unit says so.
  • A cached unit cannot read the request. Read outside, pass in. That constraint is the correctness guarantee.
  • updateTag for the writer, revalidateTag(tag, "max") for everyone else. Using one where you need the other is either a stale read or a thundering herd.

Sources: the Next.js 16 documentation shipped in next/dist/docsuse cache, cacheComponents, revalidateTag, updateTag

Next step

Tell us what is broken or what should exist.

Send the shape of the problem and any constraints you already know — budget, deadline, the stack you are stuck with. You will get a written reply from the engineer who would do the work, not a sales sequence.