Your route has a loading.tsx. The skeleton is supposed to paint immediately. Instead, the browser sits on a blank document for the length of one upstream API call, then the whole page arrives at once. The Network panel shows a single HTML response with no early chunk. The Performance panel shows nothing on the Main track until after that call resolves.
The usual suspect is a slow generateMetadata. That diagnosis is often right, but the fix people reach for — caching the fetch, adding a revalidate — misses the actual mechanism. In the App Router, metadata is not a head-tag side effect that runs alongside rendering. It is a render-phase dependency. Until you decide whether the shell needs it, you are tuning the wrong thing.
The shell is the contract
React’s server API documentation defines the shell precisely: “The part of your app outside of any <Suspense> boundaries is called the shell.” It “determines the earliest loading state that the user may see” (react.dev, renderToPipeableStream). The onShellReady callback “fires right after the initial shell has been rendered,” and React streams additional content after it, replacing loading fallbacks with inline scripts.
So the shell is not “the page.” It is the minimum tree that must resolve before any bytes leave the server. Everything inside a <Suspense> boundary can arrive later. Everything outside it is on the critical path by definition.
That gives you the only question that matters for a slow route: does the shell need this value to render? Not “how do I cache this fetch.” Whether the shell depends on it at all.
Why metadata is different from ordinary page data
Next.js documents the ordering directly: “Resolving generateMetadata is part of rendering the page” (Next.js docs, generateMetadata). It is not a parallel pass. It is a step in the render.
The same page explains the two outcomes:
If the page can be prerendered and
generateMetadatadoesn’t introduce dynamic behavior, the resulting metadata is included in the page’s initial HTML. Otherwise the metadata resolved fromgenerateMetadatacan be streamed after sending the initial UI.
And it explains why the function is Server Component only: “metadata must be resolved on the server before the page component is rendered.” That sentence is the whole problem. The framework’s design intent is that metadata is known before the page renders. When your generateMetadata awaits a slow upstream call, you have put that call in front of the page render, not beside it.
The crawler branch means there is no global switch
Here is where most advice goes wrong. You cannot simply “move metadata off the critical path” as a site-wide refactor, because Next.js makes the blocking-versus-streaming decision per request based on the user agent.
From the streaming documentation:
For bots that only scrape static HTML, and cannot execute JavaScript like a full browser, such as Twitterbot, Next.js resolves
generateMetadatabefore streaming UI, and metadata is placed in the<head>of the initial HTML. Otherwise, streaming metadata may be used. Next.js automatically detects user agents to choose between blocking and streaming behavior.
Read that carefully. For a non-JS crawler, the framework will block on your metadata regardless of how you structure the route. For a browser, it may stream. You do not control that branch from your component code.
The practical consequence: this is a per-route classification problem, not a global setting. A marketing page that must serve correct Open Graph tags to Twitterbot will pay the metadata cost on the crawler path by design. A logged-in dashboard that no crawler will ever index has no reason to. Treating them the same is the mistake.
The fix that is actually supported
Two documented moves, in order of preference.
1. If the value is not request-dependent, delete the function
The docs are explicit: “If metadata doesn’t depend on request information, it should be defined using the static metadata object rather than generateMetadata.” A static export const metadata removes the question entirely — there is no await, no render-phase dependency, nothing to block on.
This is the most common accidental critical-path metadata. Teams reach for generateMetadata because the title needs a template or the description comes from a CMS field that changes at build time. If the value does not vary per request, the static object is the documented path and the latency problem disappears.
2. Keep uncached data out of the shell
If the value genuinely varies per request, the next question is whether the shell needs it. Often the real blocker is not the metadata call itself but a shared data read it depends on — the same uncached fetch that also sits in a layout.
The loading.js documentation is blunt about this case:
If the layout accesses uncached or runtime data (e.g.
cookies(),headers(), or uncached fetches),loading.jswill not show a fallback for it. Without Cache Components: Navigation blocks until the layout finishes rendering.
And the prescribed remedy: “To ensure instant navigation, move uncached data fetching from layout.js into page.js, or wrap the runtime data access in your layout in its own <Suspense> boundary.”
This is the structural fix. If your layout reads cookies() or an uncached fetch, the skeleton never shows, and fixing generateMetadata alone will not move the shell. The layout blocks navigation before metadata is even reached.
One caveat worth internalizing: only data read from a source that activates a Suspense boundary — such as a Promise read with use — will suspend during rendering. “Suspense does not detect data fetched inside an Effect or event handler” (react.dev). Wrapping a component in <Suspense> does nothing if the component fetches in an effect.
Streaming changes the error contract
This is the part teams discover after they ship. Once the response body starts streaming, the headers are already gone. The docs state it plainly: “When streaming, a 200 status code will be returned… Because the response headers have already been sent to the client, the status code of the response cannot be updated.”
Next.js compensates for the SEO case by injecting a <meta name="robots" content="noindex"> tag into the streamed HTML when a 404 page is streamed, so search engines will not index the URL even though the HTTP status is 200. That handles indexation. It does not handle compliance, analytics, or any downstream system that reads the status code.
If you need a real 404, the existence check has to happen before the first Suspense fallback renders. The docs are specific: “The response body starts streaming when a Suspense fallback renders (for example, a loading.tsx) or when a Server Component suspends under a Suspense boundary. Place notFound() before those boundaries and before any await that may suspend.”
This is a routing decision, not a metadata one. It belongs in your proxy or in a fast pre-check, not in generateMetadata.
Measure the boundary, not the code
The claim “metadata is off the critical path” is only meaningful if a trace shows it. Reading the source will not tell you, because the crawler branch and the layout-blocking caveat both change the answer per route.
Use the Chrome DevTools Performance panel. Record load performance with the Record and reload button: “DevTools first navigates to about:blank to clear any remaining screenshots and traces. Then DevTools records performance metrics while the page reloads and then automatically stops the recording a couple seconds after the load finishes” (Chrome DevTools Performance reference).
Throttle CPU and network to match your field data before you trust the result. Then read the Main track. The panel “represents main thread activity in a flame chart. The x-axis represents the recording over time. The y-axis represents the call stack.” You are looking for one thing: does the metadata work — the fetch, the serialization, the head construction — appear before or after first paint?
If it appears after, the shell rendered without it and the refactor worked. If it appears before, you moved code without moving the boundary. The trace is the only evidence that settles it.
For the payload side, the Network panel gives you the response size and the timing of the first chunk. A blocked shell shows one HTML response arriving after the full metadata round-trip. A streamed shell shows an early chunk with the skeleton, then later chunks. The gap between first byte and last byte is your shell latency.
A decision rule
For each route, ask in order:
- Does the value vary per request? If no, use the static
metadataobject. Done. - Will a non-JS crawler need this metadata in the initial HTML? If yes, the blocking behavior is by design and you should optimize the fetch, not the structure. If no, continue.
- Does the shell depend on this value to render? If yes, the value is on the critical path and you should treat its latency as shell latency. If no, structure the route so the shell renders without it.
- Is a layout reading uncached or runtime data? If yes, that is your blocker, not
generateMetadata. Move the fetch topage.jsor wrap it in its own Suspense boundary. - Does the route need a real 404 status? If yes, the existence check must run before the first Suspense fallback. Put it in the proxy.
Then verify with a throttled load trace. The trace is the only thing that distinguishes a real fix from a plausible one.
FAQ
Does generateMetadata always block the shell?
No. Next.js documents that metadata “can be streamed after sending the initial UI” when the page cannot be prerendered without dynamic behavior, and that the framework “automatically detects user agents to choose between blocking and streaming behavior.” For non-JS crawlers it resolves metadata before streaming UI. For browsers it may stream. You do not control the branch from component code.
Why does my loading.tsx not show a skeleton?
Most likely a layout in the same segment is reading uncached or runtime data. The docs state that in that case loading.js will not show a fallback and navigation blocks until the layout finishes rendering. Move the uncached fetch into page.js or wrap the runtime access in its own Suspense boundary.
Can I force metadata to stream?
There is no documented switch. The behavior is determined by whether the page can be prerendered without dynamic behavior and by the detected user agent. The lever you have is structural: keep the shell free of the metadata dependency so the framework has something to stream.
Does streaming metadata hurt SEO?
The Next.js documentation states that “since streaming is server-rendered, it does not impact SEO,” and that for non-JS crawlers metadata is resolved before streaming and placed in the initial HTML. The framework handles the crawler case for you. What streaming does change is the HTTP status code contract, which is a separate concern.
What about notFound() inside generateMetadata?
The docs note that redirect() and notFound() can be used inside generateMetadata. But if the response has already started streaming, the status code cannot be updated. Place notFound() before any Suspense boundary and before any await that may suspend if you need a real 404 status.
The version pin
This article describes behavior documented in Next.js App Router (the generateMetadata and loading.js reference pages, current as of the Next.js 16.x documentation) and React’s renderToPipeableStream server API. The streaming-versus-blocking decision is user-agent dependent and is not a stable API surface — verify against the docs for the version you have pinned before restructuring a route.