Hiding a cold start: two functions passing the baton
A cold function answers in two seconds; waking a database takes fourteen. Instead of making the visitor wait, split the work across two functions and fire the wake-up on first paint. A playable quiz is the demo, and it publishes its own measurements.
You open the demo you just deployed, the one you wanted to show someone. The page shows up without keeping you waiting. Then you click the button that writes to the database, and the screen freezes for fourteen seconds.
Nothing is broken. The function cold-started, which is fast; the database cold-started, which is not, because it also has to restore its state from object storage. Both behaviours are exactly what scale-to-zero promises, and that is precisely what makes them awkward: the first visitor pays, and the first visitor is often the person you are showing the project to.
The usual answer is to pay so it doesn't happen — keep an instance warm. That is defensible, and it bills. There is another route, which costs nothing and doesn't involve speeding anything up: move the startup off the path where someone is waiting.
This article builds the whole thing. The result is playable: a ten-question quiz that wakes its database while you answer, and shows you the numbers at the end. (The page itself is in French; the mechanism is what matters here.)
First, measure what actually costs
"Cold starts are slow" is not actionable. What you need to know is which part is slow, because that is the part you will have to hide.
Decomposing it takes three measurements and some patience — you have to let the namespace empty out between them, or you are measuring warm without knowing it.
| What is measured | Result |
|---|---|
| the page alone, cold function, database untouched | it's there, you don't wait for it |
a SELECT 1 against a sleeping database, timed server-side |
14.1 s |
| the same query against one already up | a few milliseconds |
Those fourteen seconds are not an ordinary container start: before accepting its first query, the database has to restore its state from object storage. Two campaigns three days apart give 13.6 s and 14.1 s. It reproduces; it is not bad luck.
The conclusion fits on one line: the function is not the problem. Its startup disappears into an ordinary page load. It is the database's fourteen seconds that need covering, and as it happens they have no business being on the visitor's path at all.
The idea: one line, in the right place
A page needs its database at the end, not at the beginning. Between the moment it renders and the moment it writes, there is dead time the visitor fills themselves: reading, choosing, typing. That dead time does nothing. It may as well hold the wake-up.
// We fire it off; we never await it. Nobody reads this response. fetch(API + '/wake');
That's it. The fetch is never awaited and its result interests nobody: what matters is that the
request leaves, because that is what makes the database's pod exist. For the next forty seconds the
visitor plays. By the time they finally submit something, the database has been up for a while.
The cold start was not removed. It was not shortened by a millisecond. It simply happened somewhere else.
What makes the trick possible: two functions, not one
Here is the part you cannot work around. If the page and the database lived in the same function, there would be nothing to hide: the visitor would wait for the wake-up before seeing the page, since one request triggers both.
So the split is structural, not cosmetic:
| Function | Database attached | What it does |
|---|---|---|
jeu |
none | renders the HTML page and goes back to sleep. It has no DATABASE_URL and would not know what to do with one. |
quiz-api |
yes | wake-up, score write, aggregates. The only one that talks to the database. |
The function that renders must be as poor as possible. No database, no heavy dependency, nothing that lengthens its own startup — it is the one the visitor is genuinely waiting for.
On the API side, the wake route is as thin as it gets:
def _wake():
"""Wake the database and report how long that took, server-side.
No data is read: the point is not to get an answer but to make the pod exist.
"""
started = time.monotonic()
db = _client()
try:
db.execute("SELECT 1")
finally:
db.close()
elapsed_ms = int((time.monotonic() - started) * 1000)
return {"ready": True, "query_ms": elapsed_ms, "was_cold": elapsed_ms > 1000}A SELECT 1. The goal is not to read anything but to force the pod into existence and pay for the
restore right away.
Linking two functions without hardcoding anything
The two functions have to find each other. The simplest solution — writing the API's URL into the page — works right up until someone redeploys the pair under another account and spends twenty minutes wondering why the quiz is writing to your database.
On Zerolith, two functions on the same account differ only in their first segment: jeu-fn-<id> and
quiz-api-fn-<id>. So the page can derive its twin's address from its own Host:
def _api_url(request):
explicit = os.environ.get(API_ENV)
if explicit:
return explicit.rstrip("/")
headers = {k.lower(): v for k, v in (request.headers or {}).items()}
host = (headers.get("host") or "").split(":")[0]
if host.startswith(UI_NAME + "-"):
return "https://" + API_NAME + "-" + host[len(UI_NAME) + 1 :]
return ""With an environment variable that takes over if you name your functions differently. The pair is then redeployable as-is, with no reconfiguration.
The k.lower() on line 5 is not decorative. request.headers is a plain dictionary that
preserves the original casing: the key is Host, not host. A request.headers.get("host")
returns None without signalling anything, the page answers 500, and the test bench — which builds
its headers in lowercase — stays green. That version of the page did reach production before it was
fixed.
CORS, and the preflight
The two functions have different hostnames, therefore different origins. The API has to return CORS headers, or the browser will receive the response and refuse to hand it to the page.
CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "content-type",
"Cache-Control": "no-store",
}And that is not enough. Those headers only count for requests the browser considers "simple".
A POST in application/json is not one: the browser first sends an OPTIONS preflight, and only
sends the POST if the answer permits it.
So something has to answer that preflight, and that something is your handler: the runtime hands it
OPTIONS like any other method. You therefore decide your own policy, in three lines.
if request.method == "OPTIONS":
return 204, b"", CORScurl -i -X OPTIONS https://quiz-api-<id>.api.zerolith.io/score \
-H 'Origin: https://jeu-<id>.api.zerolith.io' \
-H 'Access-Control-Request-Method: POST'
HTTP/2 204
access-control-allow-origin: *
access-control-allow-methods: GET, POST, OPTIONS
access-control-allow-headers: content-typeThe page posts as text/plain:
headers: {'content-type': 'text/plain;charset=UTF-8'}That is a choice, not an obligation. A POST in text/plain is a simple request: it goes
straight out with no preflight, and so without the round trip that precedes one — which matters
when the whole article is about counting milliseconds. Nothing changes server-side;
request.json() parses the bytes it received without consulting the Content-Type.
How much time do you need to cover?
That is the only real design question. A fourteen-second budget is not covered by a two-second animation.
The quiz is calibrated for it, and not by accident: ten questions, at most eight seconds each, and an explanation after every answer. Even someone answering at random without reading cannot finish in under twenty-odd seconds. The coverage therefore does not depend on the visitor's goodwill; it is in the mechanics.
That is the criterion to carry into your own case: the time you put in front of the wake-up must be time the visitor would have spent anyway. A form to fill in, a multi-step flow, a page of rules to read, a choice to make. If you have to invent a wait in order to cover the startup, you have hidden nothing — you have added latency and relabelled it.
And when the dead time doesn't exist — a deep link that must show a piece of data immediately — this
technique doesn't apply. What remains is the real lever, the one that bills: an instance kept warm
(min_scale, or always_on on a database). That is not a failure, it is a dial, and it is better
set knowingly.
The demo publishes its own numbers
An article that asserts "fourteen seconds" is asking to be believed. Better to have the page produce the evidence itself.
Every game records three columns that have nothing to do with the quiz: whether the page had
prewarmed the database, how long the wake-up took, and how long the final write took as seen from
the browser. GET /stats turns those into a comparison of the two regimes across all players —
and ?prewarm=0
replays the same game without the prewarming, so you can feel the difference rather than read about
it.
Two details are paid for at design time rather than after.
Here is what the first two recorded games gave, played on this pair with the namespace emptied before each — no pods at all, neither function nor database:
| with prewarming | without | |
|---|---|---|
| score write, as seen from the browser | 260 ms | 17,462 ms |
Sixty-seven times. Same code, same database, same platform: the only difference is when the first request leaves.
One clarification the numbers deserve: in the prewarmed game, the wake-up cost 16.5 s as seen from the page — the database's 14.1 s plus the API's own cold start, since it was cold too. So 16.5 s of startup really did happen while the player was answering, for 260 ms of actual waiting at the end.
The write's duration arrives on a second request. A browser cannot know how long a request took before it finishes, and that duration is exactly what we want to publish. The page measures it, then posts the value on a second call — tiny, and against a database that is warm by then.
That write happens once per game. The clause is three words long:
UPDATE games SET submit_ms = ? WHERE id = ? AND submit_ms IS NULL
Without the IS NULL, anyone could rewrite any game's latency and move the median the page
displays. A game can only be timed once, by whoever just played it. And when the route refuses, it
answers {"recorded": false}: saying "recorded" about a refused write would be a response that
lies.
What it costs
Nothing more than before, and that is the point.
The wake-up request is a SELECT 1: one invocation, a few milliseconds of compute. The database's
pod would have started anyway — the visitor was going to write their score. It simply starts forty
seconds earlier, and that shift is free.
The only real expense is the second function, which costs nothing while nobody calls it: at rest, a function has no pod at all. On a pair like this one, the bill is identical to a single function doing the same work — plus one invocation per visit.
Compare that with the alternative: an always_on database keeps a pod up permanently, several euros
a month for the same absence of waiting.
Deploying it
The code for all three functions is in the examples repository, with its schema and instructions:
quiz/.
Three functions and one database: a migrate-kind migrator that lays down the schema — no route and
no hostname, so private by construction — the public API with
the database attached, and the public page with no database — that is the one place you can get
it interestingly wrong. Attach a database to the rendering function and it won't start any slower,
but you will have nothing left to hide: the two wake-ups become one again.
The reasoning about private databases, their schema and their billing is developed in A stateful API, which is the natural starting point if you have never attached one.
Try it on your own account
Signing up comes with credit, enough to deploy, schedule and measure everything above, without getting a card out.
Get started »