Konrad Kowalski (rootsher)Principal Platform & Reliability Architect100001010101100010000110000111001000011000001011

Deferred generation: don't build what nobody requests

date
category
Frontend
also in
Caching
reading
3 min / 653 words

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:

text
build
  |
  v
render every known page
  |
  v
store HTML
  |
  v
deploy

Deferred generation:

text
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:

text
don't compute until needed

This is not simply SSR

At first glance the first request looks exactly like SSR:

text
request
  |
  v
server render
  |
  v
HTML

The difference appears on the second request.

SSR:

text
request A -> render
request B -> render
request C -> render

Deferred generation:

text
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:

text
few popular products
████████████████████████

long tail
██████
████
██
█
█
█

Generating all 5 million pages during deployment has several problems:

text
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.

text
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:

text
marketplaces
user profiles
company directories
product catalogues
location pages
large content archives

The first request becomes special

Deferred generation does create a new asymmetry:

text
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:

text
request
  |
  v
wait for generated HTML?

or:

text
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:

text
render("/products/42") -> HTML

Classic SSR runs the function every time.

Deferred generation logically does something close to:

js
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:

text
/products/new-iphone

A second after publication 1000 users arrive.

The cache does not exist yet.

A naive implementation may do:

text
request 1 -> render
request 2 -> render
request 3 -> render
...
request 1000 -> render

We have just lost most of the benefit.

A better model:

text
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:

text
/products/42

Is that one page?

Maybe a user in Poland sees:

text
129 PLN

and a user in Germany:

text
29 EUR

If the output depends on:

text
path
country
language
currency

then the logical cache key looks more like:

text
/products/42:pl:PLN

than just:

text
/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:

html
<p>129 zł</p>

At 14:00 the price changes to:

text
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:

text
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.