Deferred generation: don't build what nobody requests
SSG has a great property: a user's request can go straight to previously prepared HTML.
The problem shows up when the number of possible pages grows faster than the number of pages anyone will actually visit.
A marketplace with a million products may have an enormous catalogue, but 80% of the traffic goes to a small share of the listings. Generating the whole catalogue on every build means doing work for pages nobody may ever open.
The natural idea is:
do not generate the page during the build, generate it on the first request.
SSG, but lazy
Classic SSG:
build
|
v
render every known page
|
v
store HTML
|
v
deploy
Deferred generation:
build
|
v
deploy without every page
first request /products/928374
|
v
render page
|
v
store result
|
v
return HTML
next request
|
v
serve stored result
The first user pays the rendering cost.
Everyone after receives a static artifact.
This is very similar to lazy initialization known from programming:
don't compute until needed
This is not simply SSR
At first glance the first request looks exactly like SSR:
request
|
v
server render
|
v
HTML
The difference appears on the second request.
SSR:
request A -> render
request B -> render
request C -> render
Deferred generation:
request A -> render -> cache
request B ----------> cached HTML
request C ----------> cached HTML
That is an important distinction.
SSR assumes the result is computed on the request path.
Deferred generation treats the request merely as a trigger for materialising an artifact.
Once generated, the page behaves more like SSG.
A commercial example: a marketplace
Assume a platform with 5 million listings.
Traffic is usually not evenly distributed:
few popular products
████████████████████████
long tail
██████
████
██
█
█
█
Generating all 5 million pages during deployment has several problems:
a very long build
a lot of storage
the cost of rendering pages with no traffic
harder deployments
having to rebuild an enormous number of artifacts
Instead we can generate only the most popular pages during the build.
build:
/products/1
/products/2
/products/3
And product number 4928371 will come into being only when somebody opens it for the first time.
That fits systems with long-tail content very well:
marketplaces
user profiles
company directories
product catalogues
location pages
large content archives
The first request becomes special
Deferred generation does create a new asymmetry:
first visitor
|
v
slow path
everyone after
|
v
fast path
If the render takes 600 ms, the first user may get a response significantly later than the rest.
That is similar to a cold start.
So the system has to decide what to do during the first materialisation:
request
|
v
wait for generated HTML?
or:
request
|
v
return fallback
|
v
generate in background
Different implementations can make that decision differently.
A curiosity
Deferred generation can be described as memoising the render function.
Assume:
render("/products/42") -> HTML
Classic SSR runs the function every time.
Deferred generation logically does something close to:
if (cache.has(path)) {
return cache.get(path);
}
const html = render(path);
cache.set(path, html);
return html;
A real system is of course harder: the cache may be distributed, the render may happen in a different instance, and many requests may arrive at once.
But the mental model is exactly that:
compute once, reuse many times.
And what if 100 requests arrive at the same time?
This is where it gets interesting.
Imagine a new page:
/products/new-iphone
A second after publication 1000 users arrive.
The cache does not exist yet.
A naive implementation may do:
request 1 -> render
request 2 -> render
request 3 -> render
...
request 1000 -> render
We have just lost most of the benefit.
A better model:
request 1
|
v
start generation
request 2 ─┐
request 3 ─┼─> wait for same result
request 4 ─┘
generation finished
|
v
all requests receive artifact
This mechanism is known as request coalescing or single-flight.
One request does the expensive work while the others wait for the same result.
That is a very important detail in systems that generate content on demand.
The cache key becomes part of the architecture
Assume we have:
/products/42
Is that one page?
Maybe a user in Poland sees:
129 PLN
and a user in Germany:
29 EUR
If the output depends on:
path
country
language
currency
then the logical cache key looks more like:
/products/42:pl:PLN
than just:
/products/42
Every extra dimension increases the number of possible variants.
And suddenly the problem we tried to solve starts coming back.
Deferred generation does not remove the combinatorial explosion.
It only lets us avoid materialising combinations until they are needed.
Where the next problem appears
Assume the product was generated in the morning:
<p>129 zł</p>
At 14:00 the price changes to:
119 zł
The cache still holds the old HTML.
Deferred generation answered the question:
when do we generate the page for the first time?
It did not answer:
when do we generate it again?
We can of course delete the artifact by hand and let the next request create a new one.
But in a larger system we need a more formal strategy:
how long the result is valid
when to consider it stale
whether the user may receive an old result
who performs the regeneration
what if several requests want to trigger it at once
At that point we stop talking only about generating pages.
We start talking about cache consistency and revalidation.
And that is where the next model comes from: ISR and static output that can expire.