Scaling PPR Beyond Vercel: How the Resume Protocol Turns CDN + Origin Into a Single Stream
I spent weeks trying to make Next.js Partial Prerendering work on a self-hosted Cloudflare setup before I realized the entire architecture assumes you're running on Vercel's unified runtime. The moment I discovered the resume protocol—specifically the next-resume: 1 header and the postponedState blob—everything clicked into place. PPR isn't just about shipping a static shell with dynamic holes; it's about orchestrating a precise handshake between your CDN edge and your origin server so that the two phases feel like one seamless HTTP stream. If you're running Next.js on AWS, Cloudflare, or bare metal, understanding this protocol is the difference between a broken PPR deployment and sub-100ms TTFB with full dynamic capabilities. Let me walk you through every byte-level detail I wish I'd known from the start.

What PPR Actually Splits: The Static Shell and Dynamic Holes
I see Partial Prerendering as a surgical approach to rendering that fuses static and dynamic output without forcing developers to learn new APIs. At build time, Next.js generates a static shell for every page, but it deliberately punches holes where dynamic content will eventually live. These aren't empty voids—they're Suspense fallbacks that act as placeholders for the dynamic regions. This means the markup that hits the CDN is never incomplete; it's a fully renderable HTML document that just happens to contain fallback UI in specific boundaries. The build process intentionally stops at these boundaries, leaving the server with a clear map of what can be cached indefinitely versus what must be resolved per-request.
At request time, the mechanics get interesting. The cached static shell ships immediately from the edge, enabling sub-100ms TTFB because the CDN never waits for the origin. While the browser starts parsing scripts, stylesheets, fonts, and that static markup, the origin server is already working in parallel to render the dynamic chunks. This parallelism is possible because PPR sits on top of React's streaming architecture, so the server doesn't wait for the entire page to resolve before it starts sending bytes. The client and server essentially race against each other, but in a coordinated way: the client hydrates what it can while the server fills in the gaps.
What strikes me about the implementation is that PPR reuses the exact same primitives powering ISR and SSR. The static shell isn't a second-class citizen—it carries full Incremental Static Regeneration behavior. That includes:
- On-demand revalidation via
revalidatePath()andrevalidateTag() - Time-based revalidation through
cacheLife() - Tag-based revalidation using
cacheTag()andupdateTag()
So the shell can be refreshed independently of the dynamic holes, which gives the architecture a lot of operational flexibility. You don't have to rebuild the entire route just because a static header or footer changed; the shell regenerates through the same cache semantics you already use for ISR pages.
The cut line between static and dynamic isn't something you manually configure. Next.js detects dynamic behavior automatically when a component accesses request-aware features. If a component reads cookies() or headers(), the framework switches to dynamic rendering, but it stops at the closest Suspense boundary. That boundary becomes the precise dividing line: everything outside stays static, everything inside becomes request-specific. I find this elegant because it means you don't have to explicitly mark routes or segments; the framework infers the split from your data-fetching patterns. The Suspense boundary effectively acts as a contract between the build system and the runtime.
How the Two-Phase Response Actually Works
The net effect is a two-phase response for every route. Phase one is the shell-first delivery from cache. Phase two is the streamed dynamic completion. Here's where the hydration strategy gets clever: the server injects inline <script> tags that progressively swap the fallback HTML with the resolved dynamic content. The browser doesn't re-render the whole page; it surgically replaces the Suspense placeholders as chunks arrive. This keeps the main thread responsive and avoids the jarring layout shifts you'd expect from a full client-side takeover. Because the static shell is already parsed and rendered by the time these scripts execute, the user sees a functional page immediately, with dynamic regions enhancing themselves in place. It's a streaming pattern that treats the CDN and the origin as a single continuous pipeline rather than two disconnected hops.

The Two Artifacts Every PPR Route Produces at Build Time
I see the build output of a PPR route as a tightly coupled pair rather than a single file. At build time, Next.js generates two distinct artifacts: the static HTML shell and the postponedState blob—an opaque serialized string that captures the exact state of dynamic components at the moment of prerendering. These two pieces are not optional companions; they are hard dependencies. If I serve a fresh shell alongside a stale postponedState, or push an updated blob to a CDN edge that still caches the old shell, the dynamic content that eventually streams from the origin will be wrong. The mismatch corrupts the resume flow entirely, because the serialized state no longer aligns with the markup that the browser has already parsed.
Locating the Artifacts in the Build Manifest
To extract these artifacts correctly, I parse the build output manifest and look for three specific signals:
renderingMode: 'PARTIALLY_STATIC': This flag appears inside theprerendersarray. Any entry carrying this mode is a PPR route that requires dual-artifact handling; without it, I know the route is either fully static or fully dynamic and does not participate in the resume protocol.fallback.postponedState: Once I identify a PPR entry by its rendering mode, I read this field to retrieve the opaque serialized string. Iterating overoutputs.prerendersand pulling this property gives me the blob that must travel with the shell.pprChain.headers: Each prerender entry exposes this field, which carries the resume protocol headers—specifically{ 'next-resume': '1' }. This header signals to the platform that the route expects the two-artifact pairing to be respected across the edge network.
Revalidation makes the atomicity requirement even sharper. Next.js regenerates both the shell and the postponedState together as a single logical unit, but the platform adapter must ensure this atomicity survives the trip to persistent storage. I use requestMeta.onCacheEntryV2 in the adapter to observe cache updates in real time. When this hook fires, I know a regeneration has completed, and I can propagate both artifacts to storage simultaneously. Missing this hook, or handling the shell and blob as separate cache writes, opens a race window where an edge node might serve a new HTML shell with an old postponedState. The result is a resume that starts from the wrong state, producing dynamic content that does not match the prerendered skeleton the user already sees.
This atomic pairing is the foundation of the entire CDN-to-origin resume flow. The shell reaches the browser first, giving the user immediate static content, while the postponedState acts as the bridge that allows the origin to resume streaming exactly where the prerender left off. If the bridge and the shell describe different realities, the resume protocol cannot reconcile them. For anyone building a custom platform adapter outside of Vercel, treating these two artifacts as an indivisible unit is the first technical constraint that shapes the entire architecture.

Inside the Postponed State: DynamicState.DATA vs DynamicState.HTML
When I examine the resumption protocol in Next.js's Partial Prerendering implementation, the postponed-state.ts module immediately reveals itself as the critical fork in the road. It classifies every dynamic hole into one of two categories: DynamicState.DATA or DynamicState.HTML. The classification is not just bookkeeping—it determines whether the origin server must rerun the entire React Server Components tree or can simply resume HTML streaming from an exact pause point.
How DynamicState.DATA Forces a Full RSC Re-Render
DynamicState.DATA fires when dynamic access happens during the RSC render phase itself. I see this whenever a Server Component directly reads request-specific APIs like cookies() or headers(). Because the RSC payload is tainted by request-time data, the server cannot safely reuse the statically prerendered RSC tree. It has to discard that work and re-render the entire RSC tree at request time. This path is computationally heavier, but it is unavoidable when the component logic itself depends on mutable request state.
The Efficiency Gain of DynamicState.HTML
DynamicState.HTML represents the lighter alternative. Here, dynamic access surfaces later—specifically during HTML generation, not during RSC serialization. A common trigger is a Suspense boundary that wraps dynamic data. Since the RSC layer produced clean, static payload fragments during build, the server does not need to rerun it. Instead, the Fizz renderer can resume from the exact postpone point and stream the remaining HTML. Skipping the RSC layer entirely makes this path significantly more efficient at request time.
The RenderResumeDataCache Bridge
Both postponed states share a common payload: a RenderResumeDataCache. I find this cache inside the server/resume-data-cache/ directory, and it serves as the bridge between build-time prerendering and request-time resumption. It stores two essential artifacts:
- RSC payload fragments computed during static prerendering
- Fetch cache entries captured while static holes were being processed
At request time, the resumption logic consults this cache first. If the data is already there, the server avoids redundant fetches and redundant RSC recomputation. This is what makes the resume protocol practical: it does not merely mark holes; it fills them with pre-computed material whenever possible.
Tracking Dynamic Access at Build Time
The decision to label a hole as DATA or HTML originates in dynamic-rendering.ts, a roughly 1,395-line module that acts as the sentinel during static prerendering. When any dynamic API is invoked, this module calls React.postpone(), which immediately marks the nearest Suspense boundary as dynamic.
At build time, Next.js deliberately invokes prerender() instead of the traditional renderToString(). Unlike renderToString(), prerender() understands the postpone() mechanism. It splits the output into two pieces:
- The prelude — static content and Suspense fallbacks that can be edge-cached
- The postponed state — dynamic holes tagged with their
DynamicStateclassification and resume cache
This architecture means the CDN can serve the prelude instantly while the origin handles only the postponed fragments, turning what looks like a monolithic render into a coordinated, resumable stream.

The Resume Protocol Handshake: next-resume: 1 and the POST Body
When I compare standard next start against a split CDN-to-origin deployment, the difference in rendering contracts becomes stark. In the standard setup, Next.js handles the shell and dynamic render automatically in a single pass; the server never questions whether part of the page already lives somewhere else. But in an adapter-based or edge-cached architecture, the static shell has already been served by the CDN, and the origin must be told explicitly not to waste cycles regenerating it. That is exactly where the resume protocol steps in. It is the mechanism that instructs the Next.js handler to skip shell rendering entirely and produce only the dynamic portions that were deferred inside <Suspense> boundaries.
The handshake itself is strict and lightweight. The CDN—or whichever intermediary sits in front of the origin—must send a POST request to the route with the header next-resume: 1. The request body must carry the postponedState blob, which is the serialized snapshot Next.js needs to reconstruct the exact boundary state from the initial prerender. Once the origin receives this combination, it does not attempt a full page render. Instead, it resumes execution at the deferred <Suspense> boundaries and streams the resulting chunks back to the edge. To me, this is the critical pivot point: the header and the blob together form a complete signal that shifts the server from "render a page" mode to "continue a page" mode.
Distinguishing Pure PPR Resume from Server Action Hybrids
Not every POST body follows the same layout, and the distinction matters for anyone building an adapter. In the common pure PPR resume scenario, the entire request body is the postponedState, and the x-next-resume-state-length header is unnecessary. The handler consumes the whole payload as the state blob and proceeds to fill the deferred boundaries.
The complexity spikes when the request combines a Server Action with a PPR resume. In that case, the body is a composite stream: the postponedState prefix is followed immediately by the action body. The handler cannot guess where the state ends and the action begins, so the adapter must supply the x-next-resume-state-length header containing the precise byte length of the postponed state prefix. The server uses that byte boundary to separate the two payloads, processes the action, and still renders only the deferred Suspense boundaries. I see this as a deliberate design choice to keep the transport simple—one POST, one stream—while avoiding the overhead of multipart encoding.
Adapter Responsibilities in the Resume Chain
For the protocol to actually work, the adapter has to do more than blindly proxy a POST request. It must first detect that the route is PPR-enabled and that a cached static shell already exists at the edge. Once those conditions are met, it sets pprChain.headers on the internal request before forwarding it to the Next.js handler. The adapter then sends the POST with postponedState as the body and expects the origin to render only the deferred <Suspense> boundaries, streaming the result without ever touching the shell. If any piece of this chain is missing—whether the next-resume: 1 header, the correct body format, or the pprChain.headers configuration—the origin will fall back to a full render, and the performance advantage of splitting shell from dynamic content collapses immediately.

Adapter-Based Invocation: Bypassing HTTP Entirely
When a platform invokes the handler function directly inside a serverless or edge runtime, the resume protocol can strip away the HTTP layer entirely. I see this as a meaningful optimization because it removes parsing, header serialization, and TCP overhead while keeping the same logical flow: the shell is already cached at the edge, and only the deferred boundaries need to be produced on demand. The protocol exposes two equivalent ways to trigger this in-process resumption.
Two Equivalent Invocation Patterns
The first approach mimics HTTP semantics without traversing a network boundary. I call the entrypoint handler with req.method set to 'POST', attach the next-resume: 1 header, and place the postponedState into the request body. The handler reads these signals and enters resume mode. The second approach is more direct: I pass requestMeta: { postponed: postponedState } as the third argument during handler invocation. This variant is structurally identical in outcome but bypasses the HTTP layer entirely, treating the resume trigger as a native function contract rather than a parsed request.
In both cases, the handler renders only the deferred boundaries and streams the resulting chunks to res. There is no re-rendering of the static shell, no duplicate markup, and no additional network hop between the cache layer and the origin compute. The output is a continuation of the same streamed HTML that the browser is already waiting for.
Separation of Concerns Without the Wire
I find this adapter-based model especially useful in architectures where the CDN and the origin compute are physically colocated or running in-process. Certain edge-function platforms can call the handler directly within the same isolate. Even though there is no TCP connection between a cache layer and a server, the resume protocol still enforces a clean separation: the shell is served from cache, and the dynamic portions are rendered on demand via a function call rather than an HTTP round-trip. This means I get the same PPR benefits—predictable caching for static shells, on-demand generation for personalized content—without forcing an artificial network boundary where none exists.
Deployment Support Matrix
Not every deployment target can leverage this equally. Based on the current implementation, PPR support breaks down as follows:
- Node.js server: Fully supported. The runtime can invoke the handler directly or proxy through local HTTP depending on the adapter configuration.
- Docker container: Fully supported. Containerized deployments treat the handler as a local process, making both invocation paths available.
- Static export: Not supported. A static export has no running handler process to resume from, so deferred boundaries cannot be generated on demand.
- Platform adapters: Support is platform-specific. The availability of in-process handler invocation depends on whether the target platform exposes a compatible serverless or edge runtime API.
This adapter flexibility is what makes PPR portable beyond Vercel's own infrastructure. I can map the resume protocol to whatever invocation primitive the host provides—whether that is an HTTP request, a direct function call, or a message passed between isolates—without changing the core rendering logic.

The Complete Platform Implementation Checklist
When I examine what it takes to decouple PPR from Vercel's infrastructure, the challenge isn't merely replicating a feature—it's treating the build output as a portable protocol rather than a platform-specific artifact. The integration checklist for any custom CDN architecture distills into six sequential operations that turn static and dynamic rendering into a coordinated handshake.
Hook build output via
onBuildComplete. At build time, I scan the manifest for routes flagged withrenderingMode: 'PARTIALLY_STATIC'. These are the pages Next.js has split into a static shell and deferred dynamic holes. Misidentification here means the platform will attempt to resume rendering on a route that was never meant to support PPR, which breaks expectations downstream.Store shell HTML and
postponedStateatomically. This is where I see the first hard correctness constraint in the protocol: these artifacts are not independent files. If a deployment updates the shell without the corresponding state blob, the resume request produces dynamic content that no longer matches the shell's React tree structure. The result isn't a stale page—it's a hydration mismatch or outright incorrect component output. Atomic storage is a correctness requirement, not an optimization.Serve the shell immediately from the CDN edge. The browser gets a 200 response with renderable HTML in milliseconds, before the origin has even evaluated the dynamic payload. This is the user-facing win that makes PPR feel instant.
Resume dynamic rendering via POST. The continuation request carries the
next-resume: 1header and sends thepostponedStateas the request body. I find this design choice particularly telling—Next.js isn't asking the origin to re-render the page from scratch. It's sending the serialized execution state back so the origin can resume from the exact point where prerendering paused. The origin becomes a continuation runtime, not a full renderer. This distinction matters because it keeps the dynamic response lightweight; the server only executes the suspended branches, not the entire component tree.Observe cache updates atomically through
requestMeta.onCacheEntryV2. When Next.js regenerates a PPR page—whether through ISR or a new build—it produces both artifacts together. The platform must treat that regeneration as a single transaction. Updating the shell without the matchingpostponedStatereintroduces the mismatch risk from step two, so the cache layer needs to swap both entries in unison. I view this callback as the synchronization primitive that keeps the distributed halves of a PPR page consistent over time.Implement graceful degradation. If the
postponedStateis missing, expired, or corrupted, the platform falls back to a full server render rather than failing outright. This acts as a circuit breaker that preserves availability when the protocol's stateful layer breaks down. Without this fallback, a lost state blob would effectively brick the route for users.
The Protocol as a Portability Mechanism
Looking at these six steps together, I notice the architecture deliberately separates concerns that are usually collapsed into a single server. The shell serves from the CDN edge. The dynamic continuation runs on the origin. The state transfer happens over a standardized HTTP exchange. This means PPR's resume protocol isn't merely a performance trick—it is fundamentally a portability mechanism. It allows the static and dynamic halves of a page to live on different systems, traverse CDN boundaries, and still present the user with a single uninterrupted stream where content appears as it resolves. That decoupling is exactly what transforms PPR from a Vercel-exclusive feature into a generic pattern any CDN-plus-origin stack can adopt.

When You Don't Need the Resume Protocol (and When You Absolutely Do)
When I look at a standard Next.js deployment running under next start, the resume protocol feels like an unnecessary abstraction. The server processes both the static shell and the deferred dynamic render in a single pass automatically, streaming the complete response without any manual orchestration. In this setup, there is no logical boundary between the shell and the dynamic chunks; they originate from the same Node.js process and travel through the same HTTP connection. If your architecture relies on a single internal rendering pass inside a monolithic server, you can safely ignore the resume handshake entirely.
When the Boundary Forces Your Hand
The moment I split shell generation from deferred dynamic rendering across separate systems, the resume protocol stops being optional and becomes infrastructure. I see this pattern emerge in three specific scenarios:
- CDN-to-origin architectures: The edge serves a cached shell instantly, but the origin must resume and stream the dynamic portions separately.
- Custom adapter routing: Platform-specific adapters route the initial shell through one function and deferred renders through another, requiring explicit coordination.
- Serverless function boundaries: Cold starts and concurrency limits make it impractical to hold a single function instance responsible for both the shell and all dynamic suspends.
In each case, the consumer must implement the header + POST/resume handshake to reconnect the deferred stream to its original shell. The resume protocol therefore acts as a portability mechanism for PPR orchestration across non-standard request flows, not as a universal requirement.
Error Handling Without Polluting the Stream
Error boundaries in PPR prerenders follow a strict separation principle. If a prerender errors, I have two recovery paths: discard the entire prerender and fall back to a fresh dynamic render, or allow the consumer to attempt dynamic recovery at the boundary. The onError callback inside createPrerenderRequest is particularly useful here because it observes failures silently without embedding error payloads into the output stream. This keeps the shell clean while still giving me full observability into what broke during the static generation phase. I especially appreciate this when a CDN sits in front of the origin, because a polluted shell could get cached and served to every visitor; the callback lets me log the failure externally while the structural output remains valid.
Configuration and Default Activation
Turning on PPR is straightforward but gated behind an experimental flag. I set experimental.ppr: true inside next.config.js to enable the feature. What is easy to miss is the interaction with component-level caching: when PPR is enabled alongside cacheComponents: true, it automatically becomes the default rendering model for the application. This means the resume protocol and deferred chunk behavior are not opt-in extras at the route level; they become the baseline mental model I have to design around once both flags are active. Every component boundary now has the potential to trigger a resume handshake, so my deployment architecture must be ready to handle split rendering before I flip those switches.
For me, the resume protocol is ultimately a boundary-detection tool. If my request flow stays inside a single process, Next.js handles the complexity silently. The instant I introduce a CDN cache, a custom adapter, or a serverless boundary, the protocol becomes the glue that keeps PPR coherent. Recognizing that distinction early saves me from over-engineering a monolithic deployment or under-engineering a distributed one.