A static site builds every page at deploy time. That is perfect for a blog with two hundred posts and painful for a store with two hundred thousand products, where the build takes an hour and most pages are never visited before the next deploy replaces them. Server-side rendering fixes build time but pays a function run on every request. Distributed Persistent Rendering, the approach Netlify proposed in 2021, sits between them: build the pages that matter at deploy time, render the rest on their first request, keep that rendered result as if it had been part of the deploy, and throw it all away atomically when the next deploy lands.
This page explains the model from first principles, shows On-demand Builders as the first implementation and why they are limited, and then builds the same behaviour with the durable cache, cache tags and purges that Netlify's current documentation points to. CDN fundamentals are covered in CDN architecture and general invalidation strategy in caching and cache invalidation.
The idea: two properties, not a product
DPR is defined by two properties. Distributed means rendering work is spread out over time and requests instead of being done up front in one build. Persistent means that once a page has been rendered, the result is kept and served to everyone until the deploy it belongs to is replaced; it is not a short-lived cache entry that each edge location recomputes on its own.
The persistence property is what separates DPR from ordinary CDN caching of a server-rendered page. A normal CDN cache is per location and evicts under pressure, so a page popular in one region can still be rendered again for the first visitor in every other region. DPR wants the first render anywhere to satisfy every later request everywhere. The atomicity property follows from Netlify's deploy model: every deploy is an immutable snapshot, and a page rendered for deploy 41 must never be served as part of deploy 42, because its HTML may reference assets or data shapes that no longer exist.
How it compares with the other rendering strategies
| Strategy | When HTML is produced | Freshness | Cost profile |
|---|---|---|---|
| Static generation | Every page at build time | Stale until the next deploy | Build time grows with page count; serving is nearly free |
| Server-side rendering | Every request | Always current | A function run per request; latency includes the render |
| DPR | Critical pages at build, the rest on first request | Stale until next deploy or purge | Short builds; one render per page per deploy |
| Time-based revalidation | On first request, then again after a time window | Bounded staleness | One render per page per window |
The line between the last two is thin. Adding a time to live to DPR turns it into time-based revalidation, and adding explicit purges gives you freshness on demand. In practice most sites want DPR's deploy atomicity plus event-driven purges, which is exactly what the modern header-based approach below provides.
The first implementation: On-demand Builders
Netlify shipped DPR as On-demand Builders: a serverless function wrapped in builder() from the @netlify/functions package. The first request for a path runs the function, the response is cached on Netlify's CDN, and later requests for that path are served from cache until a new deploy in the same deploy context invalidates it. An optional ttl in seconds, with a minimum of 60, makes an entry expire and regenerate.
// netlify/functions/product.js -- On-demand Builder (Lambda-style handler)
const { builder } = require("@netlify/functions");
async function handler(event, context) {
// Only the path is available: no query string, no request headers.
const slug = event.path.split("/").pop();
const product = await fetchProduct(slug); // your CMS or API client
if (!product) {
return { statusCode: 404, body: "Not found" };
}
return {
statusCode: 200,
headers: { "Content-Type": "text/html" },
body: renderProductPage(product),
ttl: 3600, // optional, in seconds; minimum 60
};
}
exports.handler = builder(handler);The design is deliberately narrow, and the limits are the reason to understand the newer approach:
- GET requests only, and the handler does not see query parameters or request headers, because the cache key is the URL path alone.
- No cache-key variation: one path is one cached page for everyone, so no per-language, per-country or per-cookie variants.
- Responses are buffered, not streamed, and the payload is capped at 6 MB.
- On-demand Builders do not support
Netlify-Vary, the cache-control headers or thedurabledirective; freshness control is the ttl alone.
Netlify's documentation now suggests considering regular functions with the durable cache instead, citing better performance and fewer invocations, so new work should start from the header-based design.
The same behaviour with the durable cache
A regular Netlify Function returns a standard Response and controls caching with headers. Netlify reads three cache-control headers and uses the most specific: Netlify-CDN-Cache-Control (Netlify's CDN only), then CDN-Cache-Control, then Cache-Control. Using the Netlify-specific header lets you cache aggressively at the edge while telling browsers to revalidate every time, so a purge takes effect for users immediately instead of after their browser cache expires.
Two directives recreate DPR. durable stores the response in a cache shared by Netlify's edge nodes, so a miss in one location is served from the durable copy instead of running the function again; that is the persistent property. stale-while-revalidate lets the edge serve the old copy while a single background request refreshes it, so users never wait on a render after the first one. The durable directive currently applies to Netlify Functions, not Edge Functions.
// netlify/functions/product.mts -- the same page with a function and the durable cache
import type { Config, Context } from "@netlify/functions";
export default async (req: Request, context: Context) => {
const { slug } = context.params;
let product;
try {
product = await fetchProduct(slug);
} catch (err) {
// Never let an upstream failure become the cached page.
return new Response("Temporarily unavailable", {
status: 503,
headers: { "Netlify-CDN-Cache-Control": "no-store" },
});
}
if (!product) return new Response("Not found", { status: 404 });
return new Response(renderProductPage(product), {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "public, max-age=0, must-revalidate", // browsers always revalidate
"Netlify-CDN-Cache-Control": "public, durable, s-maxage=3600, stale-while-revalidate=86400",
"Netlify-Cache-Tag": `product-${slug},catalog`,
},
});
};
export const config: Config = { path: "/products/:slug" };New deploys invalidate cached responses for that deploy context by default, which preserves DPR's atomicity. If a response genuinely does not depend on the deploy, such as a proxy to a CMS image, you can opt out by setting Netlify-Cache-ID; the IDs you set also become cache tags you can purge.
Invalidation: tags and targeted purges
A deploy-scoped cache is only half of freshness. When a product price changes you do not want a full deploy, and you do not want to wait for a time window. Tag responses with Netlify-Cache-Tag (Netlify also accepts Cache-Tag and passes it downstream) and purge by tag from a webhook with purgeCache from @netlify/functions, which also accepts optional deployAlias and domain to narrow the purge. Called with no arguments it purges the whole site.
// netlify/functions/cms-webhook.mts -- called by the CMS when content changes
import { purgeCache } from "@netlify/functions";
export default async (req: Request) => {
if (req.headers.get("x-webhook-secret") !== Netlify.env.get("CMS_WEBHOOK_SECRET")) {
return new Response("Forbidden", { status: 403 });
}
const { slug } = await req.json();
await purgeCache({ tags: [`product-${slug}`] }); // only pages tagged for this product
return new Response("Purged", { status: 202 });
};
export const config = { path: "/hooks/cms" };Design tags around what changes together. A product page carries its own tag plus the tags of anything it embeds, such as a category or a shared promotion; the category listing carries the category tag. A price change then purges one product and its listings, while a site-wide banner change purges a shared tag instead of the entire cache.
Cache keys: varying without fragmenting
By default, the key is the URL. Netlify-Vary adds request attributes to it: specific query parameters (query=page|per_page), request headers (header=App-Version), languages, countries (with grouping such as country=us|es+pt) and specific cookie keys (cookie=ab_test). This is what On-demand Builders could not do.
Every variant is a separate render and a separate cache entry, so vary on the smallest set that actually changes the HTML. Varying on all query parameters lets tracking parameters such as campaign tags explode the cache into near-unique entries, each costing a function run; list the parameters that matter by name. Never cache a page whose body depends on the logged-in user unless the cookie that identifies the variant is in the key, and prefer rendering personal fragments client-side.
Worked example: a 200,000-product catalogue
Suppose a store has 200,000 product pages, a build that renders about 50 pages per second, and traffic in which the top 2,000 products receive most visits. A full static build is 200,000 / 50 = 4,000 seconds, over an hour per deploy, and every content fix waits for it.
With DPR, the build pre-renders the 2,000 top products (40 seconds) plus the home and category pages, and routes /products/:slug to the function above. After a deploy, the first visitor to a long-tail product pays one render, typically a few hundred milliseconds depending on the data source; every later visitor anywhere is served from the edge or the durable cache. If 20,000 distinct long-tail pages are visited before the next deploy, that is 20,000 function runs per deploy, instead of 200,000 renders at build time or one run per request under pure server rendering.
Content changes arrive by webhook and purge only the tagged product. Pricing changes that affect many products purge a shared tag such as catalog, after which stale-while-revalidate keeps serving the old pages while fresh ones render in the background, so a mass purge does not become a stampede of slow first loads.
Failure modes and how to prevent them
- Caching an error. If the upstream API times out and the function returns a 200 with an empty page, that page is now the persistent answer. Return 5xx with
no-storeon failure and validate data before rendering. - Leaking personalised content. A page rendered with a user's cookie and cached without that cookie in the key is served to everyone. Keep cached HTML anonymous.
- Stale data after a content change. Deploy-scoped invalidation does not know your CMS changed. Wire webhooks to tag purges and alert when a purge call fails.
- First-request latency after every deploy. Frequent deploys reset the cache. Pre-render the pages that carry your traffic, or warm them with a post-deploy crawl of the top URLs.
- Cache fragmentation. Over-broad
Netlify-Varyrules multiply renders; watch function invocation counts per deploy. - Origin overload. A purge of a broad tag plus a traffic spike sends many renders to the data source at once. Keep stale-while-revalidate on and rate-limit or cache inside the data client.
Operating it
Watch three numbers: function invocations per deploy (should track distinct pages visited, not total traffic), the share of requests served from cache, and render latency at p95. Inspect the response headers of a page with curl -I after a deploy, after a purge and on a second request from another region to confirm each transition behaves as designed. Keep the render function's dependencies small, because cold starts add to first-request latency. The general trade-offs of running code at the edge versus in regional functions are covered in edge compute architecture, and a comparable platform's model in Vercel Edge Functions.
What to do next
- List your page types with counts and traffic share, and decide which are pre-built and which render on first request.
- For new work, implement on-demand pages as functions with
Netlify-CDN-Cache-Control: public, durable, s-maxage=..., stale-while-revalidate=...rather than new On-demand Builders. - Return errors with
no-storeand keep cached HTML free of per-user data. - Tag every response by the content it contains and connect your CMS webhooks to
purgeCacheby tag. - Add
Netlify-Varyonly for the specific parameters, languages or cookies that change the HTML. - After each deploy, check invocation counts and verify headers on a pre-built page, a first-render page and a purged page.