I used Next.js for a while before I could explain why clicking a link sometimes did nothing for a moment.

The server was not doing anything especially expensive. The browser already had plenty of JavaScript. Yet the next screen still waited for a network round trip.

The missing piece was the React Server Component payload for the destination route. Understanding when Next.js fetches that payload, and which parts it can reuse, made the delay much easier to explain and fix.

This is the first article in my Next.js performance series. The examples cover the App Router, the older route-level caching model, and the cacheComponents and partialPrefetching options available in Next.js 16.3 and later.

The browser needs more than JavaScript

In a client-rendered application, the browser can render a destination screen as soon as it has the relevant JavaScript. Data may still be loading, but the application can show its own loading UI immediately.

Server Components change what the browser has available. Their implementation stays on the server. The browser receives their rendered output through the RSC payload, alongside references to Client Components and the props those components need.

That payload is a serialized representation of the component tree. It is not simply an HTML document, and it is not the source code of the Server Components.

Here is a small example:

// app/posts/page.tsx import { LikeButton } from './like-button' export default function PostsPage() { return ( <main> <h1>Posts</h1> <LikeButton /> </main> ) } 
// app/posts/like-button.tsx 'use client' import { useState } from 'react' export function LikeButton() { const [liked, setLiked] = useState(false) return ( <button onClick={() => setLiked(!liked)}> {liked ? 'Liked' : 'Like'} </button> ) } 

PostsPage is a Server Component even though it has no async, await, or database query. App Router pages and layouts are Server Components by default. The 'use client' directive establishes a client boundary; components imported through that boundary belong to the client module graph.

For navigation, I find it useful to imagine that the browser has part of the destination tree, with spaces that need the server's output. The important question is whether that output arrives before or after the click.

If the required payload is missing, navigation may wait for the server. A prefetched loading boundary or static shell can still provide immediate feedback, so the presence of a Server Component alone does not make every transition slow.

What Link prefetching actually does

Next.js can fetch route data before navigation. In production, a <Link> entering the viewport normally triggers prefetching. The browser can then reuse the payload when the user clicks. Automatic prefetching is disabled in development, so next dev is not a useful way to judge its production behavior.

Before enabling Partial Prefetching, the default behavior differs by route:

Destination Default prefetch behavior
Statically rendered route Prefetch the full route
Dynamically rendered route Prefetch down to the nearest loading.js boundary, when available

prefetch={true} requests a fuller prefetch, including for dynamic routes under the older model. It can reduce the wait after a click, but it also does more server work before the user decides to visit. With Partial Prefetching enabled, the rules change again: default prefetching focuses on the shared App Shell. Next.js Link reference

The basic sequence is:

Link becomes visible → fetch reusable route content → keep it in the browser's Client Cache User clicks → render the cached content → request anything still missing 

Moving work before the click can matter more to perceived speed than shaving a few milliseconds off the server render.

Dynamic segments and dynamic rendering are different

The word “dynamic” causes a lot of confusion here.

A folder such as posts/[slug] defines a dynamic URL segment. It does not, by itself, mean that every visit requires request-time rendering. generateStaticParams can provide values that Next.js prerenders.

Dynamic rendering means rendering at request time. In the previous caching model, reading request-specific values such as cookies() or headers() opts a route into dynamic rendering. That can happen deep inside a component tree.

Imagine a mostly static article page with a small personalized control:

Article page ├─ Article title ├─ Article body └─ Personal controls └─ reads cookies() 

Without Cache Components or another partial-prerendering setup, that request-specific read affects the route's rendering mode. The title and body may be easy to render, but the route no longer has one completely static response that can serve every request. Cached data and shared layouts can still be reused; “dynamic” does not mean every cache disappears. Next.js previous caching model

Suspense helps only when its fallback is available

Wrapping the personalized control in <Suspense> lets the server stream the surrounding content and fallback before the slow part finishes.

But in the previous model, an ordinary Suspense boundary inside a dynamic page does not automatically turn everything around it into a build-time shell. The browser may still need the first server response before it knows what to render.

Click → request the destination payload → first response arrives → show surrounding content and fallback → stream the remaining content 

A prefetched loading.tsx boundary can make the initial transition feel immediate. A fallback that exists only in an unfetched destination payload cannot do that yet. This is the distinction I was missing when I thought, “I already use Suspense, so why does navigation still pause?” Next.js instant navigation guide

Network distance makes the difference obvious. While hosting one of my services on Cloudflare, I sometimes saw traffic from Korea reach Los Angeles or Hong Kong. Even a quick server render could not eliminate that round trip.

Read staleTimes as page reuse rules

The experimental staleTimes configuration controls reuse of page segments in the Client Cache:

// Defaults in the previous caching model const nextConfig = { experimental: { staleTimes: { dynamic: 0, static: 300, }, }, } export default nextConfig 

The names are easy to read too literally. The dynamic interval applies to pages that are neither statically generated nor fully prefetched. The static interval also applies to fully prefetched pages. Loading boundaries use the static interval, shared layouts can remain reusable, and browser back/forward navigation has its own caching behavior.

So dynamic: 0 does not mean “Next.js never keeps anything from a dynamic route.” It explains one particular reason an ordinary revisit may request the page segment again. The default changed from 30 seconds to zero in Next.js 15. Next.js staleTimes reference

Cache Components gives the page a reusable shell

With Cache Components, Next.js can prerender a shell while leaving request-specific content behind Suspense boundaries:

// next.config.ts const nextConfig = { cacheComponents: true, } export default nextConfig 

That gives us a different navigation sequence:

Before the click → prefetch the static shell and its fallbacks After the click → show that shell immediately → stream request-specific content into it 

I now place a boundary around the part that actually needs request-time information, rather than assuming the whole page must wait.

Enabling the flag also changes how the application expresses caching. Reusable work can use 'use cache'; uncached work that should stream needs an appropriate Suspense boundary. Existing applications may need more than a configuration change to adopt this model. Next.js caching guide

I learned that through an awkward migration. With Next.js 16.2 on Cloudflare/OpenNext, I turned the option on and off while working through middleware compatibility, authentication reads in layouts, and transition behavior. I eventually enabled it after changing hosting. That experience made me treat this as an application migration, not a free performance switch.

There is also a lifecycle detail to check. Next.js can preserve recently visited screens with React Activity. Activity hides DOM and preserves state while cleaning up Effects. Code that assumes leaving a route always destroys its DOM may need attention, particularly media elements and other work tied to the DOM itself. React Activity reference

Partial Prefetching shares the shell across URLs

Next.js 16.3 introduced another opt-in flag:

const nextConfig = { cacheComponents: true, partialPrefetching: true, } export default nextConfig 

An article list might link to /posts/1, /posts/2, and /posts/3. Those URLs can share the same structural shell even though the article content differs. Partial Prefetching lets Next.js reuse that App Shell across links to the same route pattern, instead of treating each URL as a completely separate prefetch target. Next.js 16.3 announcement

Where we read URL-specific information matters. Awaiting params at the top of a page ties more of the render to that particular URL. Passing the promise down and reading it inside a Suspense boundary leaves more URL-independent UI in the shared shell.

The default prefetch then brings in the shell; URL-specific content can stream after navigation. If a particular link deserves more eager work, prefetch={true} can also prefetch cached content that depends on its URL. It is a tradeoff between earlier work and faster transitions. Adopting Partial Prefetching

Service workers need a separate caching design

I also use Serwist to persist selected resources beyond the browser's in-memory route cache. Static assets are an obvious starting point. Persisting RSC responses is much more sensitive because their representation can depend on request headers, route state, deployment, and authentication.

My original implementation used Next.js response headers to distinguish responses and applied custom cache rules. I would not treat that snippet as a general recipe for other applications. Removing _rsc from cache keys or caching personalized responses without preserving the relevant distinctions can reuse the wrong response.

For most applications, I would first get framework prefetching and the App Shell right, then add a service worker only for a specific offline or persistence requirement. Next.js CDN caching guide, Serwist

A list already contains part of the detail page

The last improvement in my application came from the screen design.

When someone opens an item from a list, the browser already knows its title, thumbnail, and summary. Replacing all of that with a full-page spinner throws away useful information.

For the showcase in my service, I use Intercepting Routes to distinguish two ways of reaching the same URL:

Entry path What the user sees
Navigation from the list Show the cached summary immediately, then fetch the remaining detail
Direct visit or refresh Render the full detail through the normal server route

The intercepted route uses the list's existing client state as initial content. If there is no matching summary, it falls back to a skeleton. The regular route handles server data loading and metadata for direct visits.

Intercepting Routes provide the routing distinction; the shared client state provides the instant content. They do not, by themselves, guarantee a navigation without any RSC request. Next.js Intercepting Routes

Start with the delay after the click

I still like Next.js. These changes came from using it in Comwit, the service I build, and wanting the navigation to feel better.

When a transition feels slow, I now look at three things: what the browser already has, what it still needs from the server, and what it can show while that request is in flight.

A Server Component does not automatically make navigation slow. Waiting until the click to obtain the UI needed for the next screen is what I look for first.

Adapted from my original Korean article on Velog, published September 27, 2026.

Contact: X @meursyphus.