nextjs - 💡(How to fix) Fix ERR_HTTP_HEADERS_SENT and __next_metadata_boundary__ hydration mismatch when mixing "use cache" with draftMode()/cookies() in same page component [1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
vercel/next.js#92086Fetched 2026-04-08 01:48:22
View on GitHub
Comments
1
Participants
2
Timeline
4
Reactions
0
Timeline (top)
closed ×1commented ×1labeled ×1locked ×1

Error Message

Error: Expected the resume to render <div> in this slot but instead it rendered <next_metadata_boundary>. The tree doesn't match so React will fallback to client rendering. { digest: '2161942505' }

Root Cause

The root cause appears to be a conflict in how the streaming/resumption protocol handles the transition from a "use cache" boundary to dynamic request APIs within the same server component:

  1. generateMetadata runs with connection() (making it dynamic) and produces metadata
  2. The page component calls the "use cache" function, which fills/serves from cache and begins streaming
  3. After the cache boundary, the page calls draftMode() or cookies(), which attempts to read/set headers
  4. The metadata boundary marker (<__next_metadata_boundary__>) gets injected into the stream at a position that doesn't match what React expects during hydration replay — likely because the cache boundary and the metadata boundary are interleaved in a way that changes the DOM structure

The ERR_HTTP_HEADERS_SENT variant occurs when draftMode()/cookies() tries to set response headers (e.g., Set-Cookie) after the cached response has already started streaming to the client.

Fix Action

Fix / Workaround

Workarounds attempted:

  • Moving draftMode() before the "use cache" call — this breaks prerendering because draftMode() uses crypto internally
  • Wrapping the dynamic section in <Suspense> — does not prevent the header conflict
  • Removing connection() from generateMetadata — causes prerender errors due to library internals using crypto.randomBytes()

Code Example

const nextConfig = {
  cacheComponents: true,
  cacheLife: {
    custom: {
      stale: 300,
      revalidate: 3600,
      expire: 86400,
    },
  },
};
export default nextConfig;

---

import { draftMode } from "next/headers";
import { connection } from "next/server";
import { cacheTag, cacheLife } from "next/cache";
import { notFound } from "next/navigation";

// "use cache" function — fetches data within a cache boundary
async function cachedPostData(slug: string) {
  "use cache";
  cacheTag("posts", `post:${slug}`);
  cacheLife("custom");

  // Simulate a database fetch (e.g., Payload CMS, Prisma, Drizzle)
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/1`, {
    cache: "no-store",
  });
  return res.json();
}

// generateMetadata uses connection() because it calls an async library
// that internally uses crypto.randomBytes() (e.g., Effect-TS, Prisma)
export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  await connection();
  const { slug } = await params;
  // Fetch metadata separately (not cached)
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/1`);
  const post = await res.json();
  return {
    title: post.title,
    description: post.body?.slice(0, 160),
  };
}

export default async function PostPage({
  params,
  searchParams,
}: {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ draft?: string }>;
}) {
  const { slug } = await params;

  // Step 1: Fetch cached data FIRST
  const post = await cachedPostData(slug);

  if (!post) return notFound();

  // Step 2: Access dynamic APIs AFTER the cached call
  const resolvedSearchParams = await searchParams;
  const { isEnabled: draftModeEnabled } = await draftMode();
  const isDraft = draftModeEnabled || resolvedSearchParams?.draft === "true";

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      {isDraft && <p>Draft mode is enabled</p>}
    </article>
  );
}

---

Error: Expected the resume to render <div> in this slot but instead it rendered <__next_metadata_boundary__>.
The tree doesn't match so React will fallback to client rendering.
  { digest: '2161942505' }

---

Error: Cannot set headers after they are sent to the client
  at g.setHeader (.next/server/chunks/ssr/[root-of-the-server]__*.js)
  { code: 'ERR_HTTP_HEADERS_SENT', digest: '1390897066' }

---

Operating System:
  Platform: linux (Vercel serverless)
  Arch: x64
Binaries:
  Node: 24.x
  pnpm: 9.15.9
Relevant Packages:
  next: 16.2.1
  react: 19.1.2
  react-dom: 19.1.2
RAW_BUFFERClick to expand / collapse

Link to the code that reproduces this issue

(minimal reproduction inline below — a standalone repro repo can be created if needed)

To Reproduce

  1. Create a Next.js 16 app with cacheComponents: true
  2. Create a page that:
    • Calls a "use cache" function first (e.g. to fetch data from a database)
    • Then accesses a dynamic request API (draftMode(), cookies(), or searchParams) in the same component body
  3. Deploy to Vercel (or run next build && next start)
  4. Access the page — it renders correctly (HTTP 200), but server logs show errors

Minimal reproduction

next.config.ts

const nextConfig = {
  cacheComponents: true,
  cacheLife: {
    custom: {
      stale: 300,
      revalidate: 3600,
      expire: 86400,
    },
  },
};
export default nextConfig;

app/posts/[slug]/page.tsx

import { draftMode } from "next/headers";
import { connection } from "next/server";
import { cacheTag, cacheLife } from "next/cache";
import { notFound } from "next/navigation";

// "use cache" function — fetches data within a cache boundary
async function cachedPostData(slug: string) {
  "use cache";
  cacheTag("posts", `post:${slug}`);
  cacheLife("custom");

  // Simulate a database fetch (e.g., Payload CMS, Prisma, Drizzle)
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/1`, {
    cache: "no-store",
  });
  return res.json();
}

// generateMetadata uses connection() because it calls an async library
// that internally uses crypto.randomBytes() (e.g., Effect-TS, Prisma)
export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  await connection();
  const { slug } = await params;
  // Fetch metadata separately (not cached)
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/1`);
  const post = await res.json();
  return {
    title: post.title,
    description: post.body?.slice(0, 160),
  };
}

export default async function PostPage({
  params,
  searchParams,
}: {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ draft?: string }>;
}) {
  const { slug } = await params;

  // Step 1: Fetch cached data FIRST
  const post = await cachedPostData(slug);

  if (!post) return notFound();

  // Step 2: Access dynamic APIs AFTER the cached call
  const resolvedSearchParams = await searchParams;
  const { isEnabled: draftModeEnabled } = await draftMode();
  const isDraft = draftModeEnabled || resolvedSearchParams?.draft === "true";

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      {isDraft && <p>Draft mode is enabled</p>}
    </article>
  );
}

Current vs. Expected behavior

Current:

  • The page renders correctly (HTTP 200), but the server logs contain two recurring errors:

Error A — Metadata boundary mismatch (most frequent):

Error: Expected the resume to render <div> in this slot but instead it rendered <__next_metadata_boundary__>.
The tree doesn't match so React will fallback to client rendering.
  { digest: '2161942505' }

Error B — ERR_HTTP_HEADERS_SENT (intermittent):

Error: Cannot set headers after they are sent to the client
  at g.setHeader (.next/server/chunks/ssr/[root-of-the-server]__*.js)
  { code: 'ERR_HTTP_HEADERS_SENT', digest: '1390897066' }

Expected:

  • No server-side errors. The cached portion and the dynamic portion should coexist without hydration mismatches or header conflicts.

Impact

Although pages return HTTP 200, these errors cause:

  1. Performance degradation: React falls back to full client rendering, discarding the streamed SSR output
  2. Wasted server resources: The server does the full SSR work only for it to be thrown away
  3. SEO risk: Search engine crawlers may receive incomplete/inconsistent HTML before client JS executes
  4. Log noise: In production, this generates ~20-30 errors/hour on a site with moderate traffic

Observed patterns from production

ErrorFrequencyCache statusRoutes
__next_metadata_boundary__ mismatch~20/3hSTALE and HITPages with generateMetadata + connection() + "use cache" data fn
ERR_HTTP_HEADERS_SENT~2-12/hr (varies)HIT and PRERENDERPages calling draftMode() or cookies() after "use cache" fn

Key observations:

  • Error A happens on both cache: STALE (during revalidation) and cache: HIT (serving from cache)
  • Error B happens when the page accesses cookies()/draftMode() after a "use cache" call, particularly on cached or prerendered responses
  • The connection() call in generateMetadata is required because the underlying data library (Effect-TS) uses crypto.randomBytes() internally, which is disallowed during prerendering
  • Both errors come from source: serverless-middleware or source: serverless — framework-level, not application code

Analysis

The root cause appears to be a conflict in how the streaming/resumption protocol handles the transition from a "use cache" boundary to dynamic request APIs within the same server component:

  1. generateMetadata runs with connection() (making it dynamic) and produces metadata
  2. The page component calls the "use cache" function, which fills/serves from cache and begins streaming
  3. After the cache boundary, the page calls draftMode() or cookies(), which attempts to read/set headers
  4. The metadata boundary marker (<__next_metadata_boundary__>) gets injected into the stream at a position that doesn't match what React expects during hydration replay — likely because the cache boundary and the metadata boundary are interleaved in a way that changes the DOM structure

The ERR_HTTP_HEADERS_SENT variant occurs when draftMode()/cookies() tries to set response headers (e.g., Set-Cookie) after the cached response has already started streaming to the client.

Provide environment information

Operating System:
  Platform: linux (Vercel serverless)
  Arch: x64
Binaries:
  Node: 24.x
  pnpm: 9.15.9
Relevant Packages:
  next: 16.2.1
  react: 19.1.2
  react-dom: 19.1.2

Which area(s) are affected?

  • create-next-app
  • App Router: Components, Layouts, Pages
  • App Router: Data Fetching (use cache, cacheLife, cacheTag)
  • App Router: Metadata (generateMetadata)
  • Turbopack
  • Deployment (Vercel, Self-hosted, etc.)

NEXT-xxx

No existing issue found.

Additional context

Workarounds attempted:

  • Moving draftMode() before the "use cache" call — this breaks prerendering because draftMode() uses crypto internally
  • Wrapping the dynamic section in <Suspense> — does not prevent the header conflict
  • Removing connection() from generateMetadata — causes prerender errors due to library internals using crypto.randomBytes()

The recommended pattern from the docs (call "use cache" functions, then access dynamic APIs) is exactly what triggers this bug. The "use cache" docs state:

Cached functions and components cannot directly access runtime APIs like cookies(), headers(), or searchParams. Instead, read these values outside the cached scope and pass them as arguments.

We follow this guidance — dynamic APIs are called outside the "use cache" function — but the combination still produces errors when both exist in the same page component.

extent analysis

Fix Plan

To resolve the issue, we need to ensure that the dynamic APIs are accessed outside the cached scope and that the metadata boundary is correctly handled. Here are the steps:

  • Move the generateMetadata function outside the page component to avoid interleaving the cache boundary and metadata boundary.
  • Pass the required dynamic values as arguments to the page component.
  • Use a separate function to handle the dynamic APIs and call it outside the cached scope.

Code Changes

// Move generateMetadata outside the page component
export async function generateMetadata({
  params,
  searchParams,
}: {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ draft?: string }>;
}) {
  await connection();
  const { slug } = await params;
  const resolvedSearchParams = await searchParams;
  const { isEnabled: draftModeEnabled } = await draftMode();
  const isDraft = draftModeEnabled || resolvedSearchParams?.draft === "true";

  // Fetch metadata separately (not cached)
  const res = await fetch(`https://jsonplaceholder.typicode.com/posts/1`);
  const post = await res.json();
  return {
    title: post.title,
    description: post.body?.slice(0, 160),
    isDraft,
  };
}

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;

  // Step 1: Fetch cached data FIRST
  const post = await cachedPostData(slug);

  if (!post) return notFound();

  // Step 2: Get metadata and dynamic values outside the cached scope
  const metadata = await generateMetadata({ params, searchParams: {} });

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      {metadata.isDraft && <p>Draft mode is enabled</p>}
    </article>
  );
}

Verification

To verify that the fix worked, check the server logs for the absence of the __next_metadata_boundary__ mismatch and ERR_HTTP_HEADERS_SENT errors. Also, ensure that the page renders correctly and that the dynamic APIs are accessed outside the cached scope.

Extra Tips

  • Always follow the recommended pattern of calling "use cache" functions and then accessing dynamic APIs outside the cached scope.
  • Use separate functions to handle dynamic APIs and metadata generation to avoid interleaving cache boundaries and metadata boundaries.
  • Test your application thoroughly to ensure that the fix does not introduce any new issues.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

nextjs - 💡(How to fix) Fix ERR_HTTP_HEADERS_SENT and __next_metadata_boundary__ hydration mismatch when mixing "use cache" with draftMode()/cookies() in same page component [1 comments, 2 participants]