How to Put a CDN in Front of a Scale-to-Zero App
Scale-to-zero is a lovely thing to put on a slide and an awkward thing to put in front of users. The bill collapses to roughly nothing while nobody is looking at your app, which for most apps is most of the time. The catch is that "nobody is looking" is a state somebody has to leave, and by default the person who leaves it is a real human being with a browser open, waiting.
I'm Ajay; I build PandaStack, a Firecracker microVM platform where hosted apps really are allowed to go to sleep and every wake is a snapshot restore. The single highest-leverage thing you can put in front of a sleeping origin is not a faster boot path. It is a cache that answers most requests without ever asking the origin anything, and — this is the part people skip — that is willing to serve a slightly old answer while it wakes the origin behind the visitor's back.
A CDN in front of a scale-to-zero app is not a performance nicety bolted on at the end. It changes what "cold start" even means for your users, because it changes who pays for it. Set up carelessly, it changes nothing and you pay for the CDN too.
Not every request deserves to wake your app
Before touching a header, sort your traffic. A page load is not one request, it is a burst of them, and they have wildly different relationships with a cache. Roughly, in descending order of how happy a CDN is to see them:
- Hashed static assets — app-4f2a91c8.js, index-DdQ32mkQ.css, the fonts, the images the build pipeline fingerprinted. Cacheable forever by construction: the filename changes when the bytes change, so there is no such thing as a stale one. These are usually the numerical majority of requests on a page load and they should reach your origin approximately once per deploy per edge location, ever.
- Cacheable HTML — a landing page, a docs page, a blog index, a product page: anything byte-identical for every anonymous visitor. This is the category that decides whether scale-to-zero works, because it is the FIRST request of a session and therefore the one that would otherwise do the waking. Get this one cached and the rest of the post is detail.
- Personalized HTML — a dashboard rendered for a specific logged-in user. Not shared-cacheable, must reach the origin, and that is fine: by the time somebody is authenticated, the app is already awake because their login went to the origin too.
- API calls — mostly origin traffic and mostly correct that way. But a slice of every API is more cacheable than its authors assume: public read endpoints, config documents, feature-flag payloads, price lists, anything a logged-out visitor can see. Finding that slice is worth an afternoon.
- WebSockets and SSE — a CDN proxies them and cannot cache them. Every one is an origin connection, and every one wakes a sleeping app immediately. If your marketing page opens a socket on load for analytics, you do not have a scale-to-zero app; you have an always-on app with extra machinery and a more complicated bill.
Do the arithmetic on a typical page: one HTML document and a few dozen assets. If the CDN serves the assets and the HTML, the origin sees zero requests and stays asleep. If the CDN serves only the assets — which is the default outcome, because assets get cached automatically and HTML usually does not — the origin sees exactly one request per visitor, which is precisely the request that wakes it. The default configuration gets you the smallest possible win from the largest possible number of requests.
max-age is for browsers, s-maxage is for the CDN
The asymmetry you want on cacheable HTML is short-or-zero for the browser and long for the shared cache. A browser holding your HTML for five minutes means one user sees stale content and you cannot do anything about it. A CDN holding your HTML for five minutes means every user is served from the edge, one background revalidation refreshes it for everybody, and a purge fixes it globally in seconds. Same duration, entirely different blast radius.
So: max-age=0 for the browser, s-maxage for the edge. The browser revalidates cheaply against the CDN with an ETag; the CDN answers it without waking anything.
# --- Cacheable HTML: the shape that makes a sleeping origin work ---
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
cache-control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400, stale-if-error=604800
etag: "g1757203841"
vary: accept-encoding
cf-cache-status: HIT
age: 137
# max-age=0 browser always revalidates (against the CDN, cheap)
# s-maxage=300 edge serves it fresh for 5 minutes
# stale-while-revalidate=1d after that, edge serves STALE instantly and
# refreshes in the background -> the wake is nobody's
# stale-if-error=7d if the origin is genuinely down, keep serving
# --- Hashed asset: the easy case, and 90% of your requests ---
HTTP/1.1 200 OK
content-type: application/javascript
cache-control: public, max-age=31536000, immutable
etag: "g1757203802"
cf-cache-status: HIT
# --- Personalized HTML: say so explicitly, do not rely on a heuristic ---
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
cache-control: private, no-store
cf-cache-status: BYPASSOne note on that last block. Most CDNs will refuse to cache a response carrying Set-Cookie, which is a sensible default and also the single most common reason a page you believed was cached is not. If you want it cached, do not set cookies on it. If you must set cookies on it, it is personalized and you should have said no-store.
stale-while-revalidate: the header that moves the wake off the critical path
Here is the whole idea, and if you take one thing from this post, take this. Without stale-while-revalidate, a cache entry has two states: fresh, served instantly; and expired, which means the next request blocks while the CDN goes and asks the origin. When the origin is a paused microVM, "goes and asks the origin" means a snapshot restore plus your framework's own startup. Some unlucky visitor is standing in that gap. On a low-traffic site, that unlucky visitor is a meaningful fraction of your visitors.
With stale-while-revalidate, expiry gains a third state. Past s-maxage but inside the SWR window, the edge returns the stale copy immediately and kicks off the refresh in the background. The wake still happens. It just happens on a connection nobody is waiting on. The visitor gets a response in single-digit milliseconds from a PoP near them and never learns that their arrival is what woke your server up.
Cold start is not a latency number, it is a question about who pays. stale-while-revalidate does not make the wake faster. It makes the wake somebody else's problem, and that somebody else is a background fetch with no user attached.
What that actually looks like from a visitor's chair, for the same sleeping origin under three configurations:
- Cache empty — the very first visitor, or the first after a full purge — No CDN: full wake, in the request. Plain max-age: full wake, in the request. stale-while-revalidate: full wake, in the request. Nothing rescues the first visitor; the entire goal is that there is exactly one of them rather than one per visitor.
- Cached copy still fresh — No CDN: full wake on every single request, because there is no cache. Plain max-age: served from the edge, origin untouched, origin stays asleep and stays cheap. stale-while-revalidate: identical — SWR does nothing until freshness expires.
- Cached copy just expired — No CDN: full wake. Plain max-age: this request blocks on the wake; one visitor watches a spinner while your app boots on their time, and they have no idea why. stale-while-revalidate: stale bytes returned instantly, refresh issued in the background, wake latency lands on nobody.
- Trickle traffic — one visit every few minutes, which is the normal state of most apps — No CDN: every visit wakes it, so the app never sleeps, never gets to be fast, and you get the always-on bill anyway. Plain max-age: one blocking wake per s-maxage window, paid by whichever visitor arrives first after expiry. SWR: one background wake per window, paid by nobody, and every visitor is served from the edge.
- Origin genuinely down — failed deploy, no capacity, bad start command — No CDN: 502, immediately. Plain max-age: 502 the moment the entry expires. stale-if-error: the last known-good copy keeps serving for as long as you configured, which converts an outage into an inconvenience you fix during working hours.
- Freshness after a deploy — No CDN: instant, everywhere. Plain max-age: potentially stale for a full s-maxage window unless you purge. SWR: stale for up to the window PLUS one more request's worth, unless you purge — which is exactly why purge-on-deploy stops being optional the moment you turn SWR on.
That last row is the honest cost. SWR trades a small amount of consistency for a large amount of tail latency, and it is a good trade right up until you ship a deploy and expect the world to see it. The fix is not a shorter window; it is a purge, which is its own section below.
Immutable assets versus HTML that must revalidate
The reason hashed assets are the easy case is that content-addressing removes invalidation entirely. A new build produces new filenames, so the old URLs are never wrong — they are just unreferenced. You can cache them for a year and mark them immutable, which tells the browser not even to send a conditional request. This is free performance and almost everybody leaves some of it on the table.
The trap is deciding which files qualify. On our static serving path I ended up with two classes rather than one pattern, because one pattern gets it wrong in both directions. There are framework contract trees, where the framework guarantees every file inside is content-addressed — Next.js /_next/static, SvelteKit /_app/immutable, Astro /_astro — and the whole subtree can be marked immutable on the strength of that contract. Then there are conventional asset directories like /assets or CRA's /static/js, where users routinely drop unhashed files copied from public/, so the filename itself has to carry a hash-looking token before we claim immutability.
The design rule underneath is about which direction the mistakes go. Failing to recognise a hashed asset means it revalidates: one conditional request, a 304, no harm done. Wrongly marking an unhashed file immutable means browsers will hold wrong bytes for a year and no purge you issue can reach them, because immutable means they never ask again. So the detector is deliberately conservative and a miss is always safe.
For HTML the correct default is max-age=0, must-revalidate with a strong ETag. That sounds expensive and is not: the revalidation is a conditional request that returns 304 with no body, and if you have also set s-maxage plus SWR, most of those 304s are answered by the edge without the origin waking at all.
The Vary trap, or how to disable your CDN without noticing
Vary lists the request headers that partition the cache. Every distinct combination of those header values becomes its own cache entry. That is a multiplier, and the multipliers available range from "harmless" to "one entry per human being on Earth".
- Vary: Accept-Encoding — harmless. Two or three entries, and most CDNs handle compression variants natively anyway.
- Vary: Accept-Language — acceptable if you genuinely serve different languages. Multiplies entries by the number of languages, not by the number of visitors.
- Vary: Cookie — almost always a catastrophe on a shared cache. One entry per distinct Cookie header. Your analytics tool sets a unique ID per visitor, so this means one entry per visitor, so your hit rate is approximately zero.
- Vary: User-Agent — effectively one entry per browser build string, which is close enough to one per visitor. Use client hints or server-side detection and keep it out of Vary.
- Vary: * — an instruction never to cache anything. If that is what you meant, write no-store instead: it is honest, it is unambiguous, and it skips a lookup.
Vary: Cookie is worth dwelling on because of what it does to a sleeping origin specifically. With a normal always-on server, a shredded hit rate costs you money and some latency. With a scale-to-zero origin, it costs you a wake — per visitor, in the request, every time. You did not merely disable your CDN; you kept paying for it while receiving nothing, and simultaneously converted a cheap architecture into an expensive one with worse p99 than the boring server you replaced.
It is rarely deliberate. It happens when a framework's session middleware touches the session object on every request, or when an auth library sets a rolling cookie on responses it did not need to. Both quietly attach Set-Cookie or Vary: Cookie to your beautifully cacheable marketing page. Check what your homepage actually returns before you believe anything about your hit rate.
Purge by tag, not by everything
There are three ways to invalidate. By URL, which is precise and requires you to know every affected URL. By tag — Surrogate-Key on Fastly, Cache-Tag on Cloudflare Enterprise — where responses are labelled on the way out and you later purge every response carrying a label. And purge-everything, which is the big red button.
Purge-everything deserves a specific warning here, because it interacts badly with a sleeping origin in a way it does not with a normal one. A full purge means every subsequent request is a miss. Every miss goes to the origin. The origin is asleep. You have just scheduled a thundering herd against a VM that has to boot before it can answer the first member of the herd — which is to say, you can denial-of-service yourself with an administrative action, and you will do it at the exact moment you were trying to fix something.
Tag-based purging is the escape. Label a product page with the product's key and the category's key; when the product changes, purge one key and invalidate the handful of pages that actually reference it. The origin wakes once and serves a handful of requests instead of your whole site.
For our own static apps we do the narrow version of this without tags at all. After a blue-green flip the deploy purges only the non-hash-addressed entry points — the root document, /index.html, /404.html — on the platform hostname and on every verified custom domain. Hashed assets are deliberately not purged, because a new build wrote new filenames and there is nothing stale to remove. The purge is fired asynchronously and every failure is swallowed: a cache that holds a revalidatable copy a little longer is a cosmetic problem, and a deploy that fails because a CDN API returned 500 is a real one.
Purge order, and the classic broken deploy
If you blue-green your origin, the CDN can end up holding a mixture: old HTML from before the flip, new hashed assets from after it. Old HTML references old asset filenames. If your deploy deleted those, the browser requests chunks that no longer exist, gets 404s, and renders a white screen with a ChunkLoadError in the console. Everybody has seen this deploy. Most people blame the framework.
It is an ordering bug, and the ordering that fixes it is not complicated:
- Upload the new hashed assets first, under their new names. Old and new coexist happily because they never share a URL, and no cache anywhere needs to be told anything.
- Flip the origin so it starts serving the new HTML. The CDN may still be handing out old HTML at this point; that is fine, because the old HTML's assets are still there.
- Purge the HTML entry points only. Now new visitors get new HTML that references new assets, both of which exist.
- Leave the old assets in place for at least as long as your HTML could still be cached — s-maxage plus the stale-while-revalidate window, plus whatever a browser might be holding. Only then garbage-collect them. Deleting old assets at flip time is what turns an ordering nuance into an outage.
- Never purge HTML before flipping. A purge before the flip re-caches the OLD document as fresh for a whole new window, so your deploy is invisible for longer than if you had done nothing at all.
Step 4 is the one that catches teams who are otherwise doing everything right, because it is the one that only fails for the visitors who were mid-session during the deploy — a small enough group that it looks like a flake, and a loud enough group to file bugs.
What a cache miss actually costs when the origin is asleep
Worth being concrete about what you are protecting people from, because the platform half and the app half are different sizes and only one of them is mine to optimise. On PandaStack a create is a snapshot restore rather than a boot: the restore step itself is around 49 ms, and a full create measures a p50 of about 179 ms and a p99 around 203 ms. If a template has never been baked and the platform has to do a genuine cold boot instead, that is roughly 3 seconds — a one-time cost, but a visible one if it lands on a person.
Then your application starts. It imports its dependency tree, connects to a database, warms whatever it warms, and finally binds a port. For most real frameworks that half is larger than the platform half, sometimes by an order of magnitude, and no amount of hypervisor cleverness touches it. This is the honest reason a CDN beats a faster boot path: I can make the restore fast, and I cannot make your Django app import pandas faster.
The second cost of a miss is the stampede. If ten visitors arrive at an expired entry simultaneously and the CDN has no request coalescing, that is ten origin requests, all of them queued behind the same wake. Turn on coalescing — proxy_cache_lock in nginx, origin shield or its equivalent on a hosted CDN — so that one request goes to the origin and the other nine wait at the edge for its answer. Our own router does the same trick a layer down, holding exactly one request as the wake claim winner and parking the rest on a self-refreshing page, but coalescing at the edge is strictly better because the coalesced requests never leave the PoP.
Bots and scanners should not get to wake your app
A CDN does not solve this one, which surprises people. A scanner requesting /wp-login.php, /.env or /admin is requesting URLs your cache has never seen, so every one of those is a miss and every miss goes to the origin. An app on a public custom domain attracts a steady drizzle of this traffic that the obscure default hostname never sees. Left alone, the drizzle is enough to keep a scale-to-zero app permanently awake, which is a bill you are paying entirely to serve 404s to people attacking you.
So the router classifies inbound traffic on two independent axes before it decides anything, and the interesting part is choosing the direction of each false positive by blast radius rather than by accuracy.
// api/cmd/api/apps_traffic.go -- two axes, not one flag.
//
// keepsWarm=false -> served normally, but does NOT reset the idle timer.
// A false positive just makes the app sleep sooner, which
// is safe -- so this set is BROAD.
// allowsWake=false -> a request to a HIBERNATED app gets a cheap 200 instead
// of a cold boot. A false positive withholds the real app
// from a real client -- so this set is NARROW.
func classifyAppRequest(r *http.Request) (keepsWarm, allowsWake bool) {
path := strings.ToLower(r.URL.Path)
ua := strings.ToLower(strings.TrimSpace(r.Header.Get("User-Agent")))
// A named uptime monitor is a monitor on ANY path. It never needs real
// content. This must beat the health-path rule below, because monitors
// overwhelmingly probe /healthz -- and that is precisely the request that
// would otherwise cold-boot the app on every interval, forever.
for _, tok := range monitorUATokens { // uptimerobot, pingdom, checkly, ...
if ua != "" && strings.Contains(ua, tok) {
return false, false
}
}
// HEAD is a probe; favicon/robots.txt are browser chrome, never a real
// endpoint. Neither warms nor wakes.
if r.Method == http.MethodHead || infraAssetPaths[path] {
return false, false
}
// Automated-but-serve-real-content: never keeps the app warm, but DOES wake
// it, so a real client is never handed a placeholder. Search crawlers live
// here -- Googlebot must see the actual page, it just must not be the
// reason the app never sleeps.
if infraHealthPaths[path] || ua == "" || isCrawler(ua) {
return false, true
}
return true, true // real user traffic
}The asymmetry is the whole design. Getting keepsWarm wrong costs you a slightly earlier sleep. Getting allowsWake wrong costs a real visitor the real page, so that set contains only named monitors, HEAD requests and pure asset chrome — never a browser, never a generic API client, and never an SEO crawler, all of which must see real content.
Your health check is defeating the entire feature
This is the one I want to leave people with, because it is so common and so quiet. You build scale-to-zero. You add a load balancer or an uptime monitor that probes / every 10 seconds, because that is what one does. The app now receives a request six times a minute forever. It never sleeps. It never scales to zero. You are paying the always-on bill and you have all the scale-to-zero machinery installed, including its cold starts, which you now also experience whenever something restarts.
A CDN does not save you here either: a health check that hits your origin hostname directly bypasses the cache by design, and a health check that goes through the CDN and gets a cached 200 is not checking your app's health at all. Both outcomes are wrong in different directions, which is a good sign that the health check is asking the wrong component.
The answers, roughly in order of how much I like them. Best: do not probe the app at all — ask the platform for the app's status, since it already knows whether the app is running, hibernated, or broken, and asking costs no wake. Next: probe with a User-Agent your platform recognises as a monitor, so the router can answer it cheaply. Worst but sometimes necessary: keep the probe and accept that this app is not scale-to-zero, and be honest about that in the cost model rather than discovering it on the invoice.
The configuration, in two flavours
If you are running your own edge, nginx already implements every idea in this post; the directives are just named differently from the HTTP headers.
proxy_cache_path /var/cache/edge levels=1:2 keys_zone=app:64m
max_size=10g inactive=7d use_temp_path=off;
upstream origin {
server app-origin.internal:8080;
keepalive 16;
}
server {
listen 443 ssl http2;
server_name example.com;
# Hashed assets: content-addressed, so cache hard and never revalidate.
location ~* "-[a-zA-Z0-9_-]{8}\.(js|css|woff2|png|svg)$" {
proxy_pass http://origin;
proxy_cache app;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header X-Cache-Status $upstream_cache_status always;
}
location / {
proxy_pass http://origin;
proxy_cache app;
# Ignore an upstream that tells us not to cache its own HTML. Remove
# this line if you trust your app's headers -- but audit them first,
# because a session middleware you forgot about is probably emitting
# Set-Cookie on your homepage right now.
proxy_ignore_headers Set-Cookie Cache-Control Expires;
proxy_hide_header Set-Cookie;
proxy_cache_valid 200 301 302 5m; # ~= s-maxage=300
proxy_cache_valid 404 30s;
# stale-while-revalidate + stale-if-error, in nginx dialect.
proxy_cache_use_stale updating error timeout invalid_header
http_500 http_502 http_503 http_504;
proxy_cache_background_update on; # THE line: refresh off-request
proxy_cache_lock on; # coalesce a miss stampede
proxy_cache_lock_timeout 30s; # generous: the origin may be waking
proxy_cache_revalidate on; # conditional GETs upstream
# The origin might be asleep. Do not give up before it can answer.
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
add_header Cache-Control "public, max-age=0, s-maxage=300" always;
add_header X-Cache-Status $upstream_cache_status always;
}
}On a hosted CDN you express the same policy in response headers instead, which for a Next.js app means the headers() block in the config for anything static and an explicit Cache-Control per route for anything dynamic.
// next.config.ts -- the static half of the policy.
import type { NextConfig } from "next";
const config: NextConfig = {
async headers() {
return [
{
// Next fingerprints everything under /_next/static, so the whole
// subtree is immutable by contract. This is the free win.
source: "/_next/static/:path*",
headers: [
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
],
},
{
// Files copied verbatim from public/ are NOT hashed. Cache them at the
// edge, revalidate in the browser, and never mark them immutable --
// logo.png held for a year is a logo you cannot change.
source: "/(.*)\\.(png|jpg|svg|webp|ico)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=0, s-maxage=604800, stale-while-revalidate=86400",
},
],
},
];
},
};
export default config;
// app/page.tsx -- the dynamic half. Anonymous, identical for everyone, and
// therefore the request that decides whether a sleeping origin ever wakes.
export const revalidate = 300;
export async function GET() {
return new Response(await renderHomepage(), {
headers: {
"content-type": "text/html; charset=utf-8",
// 5 minutes fresh at the edge; a day of stale-serving while it wakes
// behind the visitor; a week of last-known-good if it cannot come back.
"cache-control":
"public, max-age=0, s-maxage=300, stale-while-revalidate=86400, stale-if-error=604800",
// Say what you actually vary on. Nothing else. Especially not Cookie.
vary: "accept-encoding",
},
});
}Verifying it with curl, which you should do before believing any of this
Cache configuration is exactly the kind of thing that looks right in a config file and is wrong on the wire. Two requests and the headers tell you everything.
# First request: expect a MISS. This one may have woken the origin.
$ curl -sSI https://myapp.example.com/ | grep -iE 'cache|age|vary|set-cookie'
cache-control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400
cf-cache-status: MISS
vary: accept-encoding
# Second request, immediately after: expect a HIT and an age counter.
$ curl -sSI https://myapp.example.com/ | grep -iE 'cache|age'
cache-control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400
cf-cache-status: HIT
age: 3
# Wait past s-maxage and ask again. The right answer is a fast STALE (or
# REVALIDATED) -- NOT a slow MISS. A slow MISS here means SWR is not in effect
# and some real visitor is going to eat that wall-clock time instead of you.
$ curl -sS -o /dev/null -w 'status=%{http_code} cache=%{time_total}s\n' \
-D- https://myapp.example.com/ 2>/dev/null | grep -iE 'cache-status|status='
cf-cache-status: STALE
status=200 cache=0.041s
# Confirm hashed assets are actually immutable, not merely cached.
$ curl -sSI https://myapp.example.com/_next/static/chunks/main-4f2a91c8.js \
| grep -i cache-control
cache-control: public, max-age=31536000, immutable
# The hit-rate assassin. If either of these shows up on a page you expect to
# be shared-cached, stop and fix it -- nothing else in this post will help.
$ curl -sSI https://myapp.example.com/ | grep -iE '^(vary|set-cookie):'
vary: accept-encoding # good
# vary: cookie # one cache entry per visitor
# set-cookie: sid=... # most CDNs refuse to cache this at allThe third check is the one worth automating. Everything else is a static property of your headers, but "does a stale hit actually serve fast?" is a property of your CDN's behaviour under your configuration, and it is the property the entire architecture rests on.
The whole thing as a checklist
- Classify your traffic first. Know which of your URLs are shared-cacheable, which are personalized, and which open a socket. The third group is why your app is not sleeping.
- Split max-age from s-maxage. Zero for the browser, minutes for the edge, and let the ETag make the browser's revalidation cheap.
- Add stale-while-revalidate to every shared-cacheable HTML response. This is the single header that moves the wake off a user's request.
- Add stale-if-error with a much longer window. Days. It is the difference between a bad deploy and an outage.
- Mark genuinely hashed assets immutable, and nothing else. A missed asset revalidates harmlessly; a wrongly-immutable one is wrong for a year in caches you cannot reach.
- Audit Vary and Set-Cookie on your cacheable pages. Vary: Cookie means one entry per visitor, which means one wake per visitor, which means you have built the expensive version of everything.
- Purge by tag, and never purge everything at an origin that is asleep — that is a thundering herd you scheduled yourself.
- Order your deploy: assets up, origin flipped, HTML purged, old assets retained past the full stale window. Skipping the last step is the white-screen ChunkLoadError deploy.
- Turn on request coalescing so a stampede of misses becomes one origin request and a queue at the edge.
- Stop probing your app every ten seconds. Ask the platform for status instead, or accept that this app is always-on and put that in the cost model.
None of this is exotic. It is 1999-era HTTP semantics plus one header from 2010, applied to an origin that is allowed to be absent — and that last part is the only genuinely new thing. Scale-to-zero moved the cost of a request from "a bit of CPU" to "a boot", and the cache in front of it went from a nice optimisation to the component that decides whether the architecture is a good idea at all.
Get it right and your users see edge latency, your origin sees a trickle of background revalidations, and your bill reflects actual usage rather than the fear of usage. Get it wrong and you have built an always-on app that also has cold starts, which is the only configuration strictly worse than either alternative.
Frequently asked questions
Does a CDN actually fix cold starts for a scale-to-zero app?
It does not make the cold start faster; it changes who experiences it. Without a cache, every arrival at a sleeping origin blocks on the wake. With a shared cache holding a fresh copy, most arrivals never reach the origin at all and the origin simply stays asleep. The decisive piece is stale-while-revalidate: when the cached copy expires, the edge returns the stale bytes immediately and refreshes in the background, so the wake happens on a connection with no user attached. The wake still takes exactly as long as it did before — it just stops being something a person waits for. The one case nothing can rescue is a genuinely empty cache, which is why the goal is to have exactly one cold visitor rather than one per visitor.
What is the difference between max-age, s-maxage, stale-while-revalidate and stale-if-error?
max-age is how long any cache may serve the response as fresh, and in practice it governs the browser. s-maxage overrides it for shared caches only, so it is the number your CDN follows. The useful asymmetry is max-age=0 with a longer s-maxage: browsers revalidate cheaply against the CDN, the CDN serves everyone from the edge, and a purge fixes the world in seconds instead of waiting for browser caches to expire. stale-while-revalidate extends the entry past s-maxage in a specific way — the edge may serve the stale copy instantly while it fetches a fresh one in the background. stale-if-error is different insurance entirely: it permits serving a stale copy when the origin returns a 5xx, times out, or refuses the connection. They are commonly set to similar values and should not be. SWR is a latency optimisation measured in hours; stale-if-error is outage insurance and should be measured in days.
Why does Vary: Cookie destroy CDN hit rate?
Vary tells the cache which request headers make responses different, and each distinct combination becomes a separate cache entry. Since a typical visitor carries at least one unique cookie value — an analytics ID, a session identifier, a consent token — Vary: Cookie means one cache entry per visitor. Nobody ever hits anybody else's entry, so the effective hit rate on that URL is near zero and you are paying for a CDN that is functioning as a slow proxy. In front of a scale-to-zero origin the consequence escalates from wasteful to serious: every one of those misses reaches an origin that may be asleep, so each new visitor pays a wake. The usual cause is not deliberate — a session middleware touching the session on every request, or an auth library refreshing a rolling cookie, will attach Set-Cookie or Vary: Cookie to pages that are otherwise perfectly shared-cacheable. Check the actual response headers on your homepage before trusting any hit-rate dashboard.
In what order should a blue-green deploy purge the CDN?
Upload the new content-hashed assets first, since they occupy new URLs and invalidate nothing. Then flip the origin to serve the new HTML. Then purge only the non-hashed entry points — the root document, index.html, any clean-URL entries, your 404 page — on every hostname that serves the app, including custom domains. Then leave the old assets in place for at least as long as old HTML could still be cached anywhere, which means s-maxage plus the stale-while-revalidate window plus whatever browsers may be holding, before garbage-collecting them. Two orderings break: purging HTML before the flip re-caches the old document as fresh for a whole new window, making the deploy invisible for longer than doing nothing; and deleting old assets at flip time leaves cached old HTML pointing at files that return 404, which is the white-screen ChunkLoadError deploy everyone has shipped at least once.
Should health checks and uptime monitors be allowed to wake a sleeping app?
Generally no, and this is the most common way scale-to-zero is silently defeated. A probe every ten seconds is six requests a minute forever, so the app never becomes idle, never sleeps, and you pay the always-on bill while still carrying every cold-start risk. A CDN does not help, because a probe pointed at your origin bypasses the cache by design, and a probe that gets a cached response is not checking your app at all. The best fix is to stop probing the app's HTTP surface and ask the platform for the app's status instead, since the platform already knows whether it is running, hibernated or failed, and asking costs no wake. Failing that, use a monitor whose User-Agent the platform recognises so it can be answered cheaply. Be aware of the trade-off in that last option: if the platform answers on the app's behalf, a green dashboard now means the router is healthy, not that your app is — so anything load-bearing for an on-call rotation needs a probe that genuinely exercises the app and a deliberate acceptance of the wake it causes.
Keep reading
- Bot traffic and the scale-to-zero wake classifier — The two-axis classifier this post leans on, worked through in full.
- The anatomy of scale-to-zero wake latency — Where the milliseconds go once a request does reach a sleeping origin.
- Scale-to-zero app hosting, explained — What the origin is doing while it is asleep, and how it comes back.
- Blue-green vs canary deploys — The flip whose ordering decides whether your cache serves a broken mix.
- How to write a health check that catches real failures — Because the fix for a wasteful probe is a better probe, not no probe.
- Custom domains and automatic TLS — The hostnames a post-deploy purge has to remember to include.
49ms p50 cold start. Fork, snapshot, and scale to zero.