Modern rendering is composition
- date
- category
- Frontend
- also in
- Software Architecture · System Design
- reading
- 4 min / 723 words
We have come a long way from the simple model:
request
|
v
server
|
v
HTML
|
v
browser
Then came AJAX, SPA, CSR, the return of SSR, SSG, deferred generation, revalidation, streaming, hydration, islands, resumability and Server Components.
It is easy to turn that into an acronym zoo.
A better mental model is simpler:
modern rendering architecture is a set of independent decisions about compute, cache, network and the client runtime.
You no longer pick one mode for the application
The question used to be:
SSR or CSR?
Later:
SSR or SSG?
Today the more sensible questions are:
when do we render?
where do we render?
what do we cache?
what do we stream?
what do we send to the client?
what do we hydrate?
These are several different axes.
One application can use many answers at once.
Take a real marketplace
Assume a platform similar to a large store or marketplace.
We have:
Homepage
Search
Category
Product page
Cart
Checkout
Account
Admin
Trying to set a single:
renderingMode = "SSR"
for the whole application is architecturally uninteresting.
Every part has different properties.
Homepage
The homepage may contain mostly marketing content:
hero
categories
campaign banners
popular products
footer
A large part of it changes relatively rarely.
A good model:
prerender
+
revalidation
+
CDN
There is no reason to render that from scratch for every user.
But a small fragment:
Hello, Anna
Your cart: 3
is personalised.
It should not destroy the cacheability of the whole document.
That is a place for a dynamic fragment or client-side activation.
Search
Search is different.
A query:
/search?q=mechanical+keyboard
has a potentially enormous number of variants.
We are not going to generate them during the build.
So search can use:
request-time data
+
server render
+
client-side interactions
The first result can be produced on the server, while later:
filters
sorting
pagination
can run on the client.
That is neither an "SSR application" nor a "CSR application".
It is a composition of both models.
The product detail page
Here it gets even more interesting.
Product Page
├── description
├── technical specs
├── price
├── availability
├── recommendations
├── reviews
├── wishlist
└── add to cart
Every fragment has different properties.
We can have:
description
-> prerender + long cache
technical specs
-> prerender
price
-> short revalidation / dynamic
availability
-> dynamic
recommendations
-> streamed
wishlist
-> client
add to cart
-> client
On top of that some components can be server-only and never reach the browser.
So a single page uses several generations of rendering architecture at once.
Cart and checkout
The cart is yet another class of problem.
Here we have:
strong personalisation
a lot of interaction
optimistic updates
frequent mutations
very fresh data
There is little point in treating the whole cart UI as static content.
A client-side runtime has a lot of value here.
Checkout can still get its initial HTML from the server, though.
So:
server initial render
+
long-lived client state
is still a sensible model.
A curiosity
The most important boundaries of a modern application often do not match routes.
We can have:
/products/42
and inside it several different boundaries:
cache boundary
streaming boundary
server/client boundary
hydration boundary
error boundary
That is five different boundaries on the same page.
A framework may express all of them through a similar component structure, but from the runtime's point of view they solve different problems.
That is why the component tree increasingly doubles as a description of the execution architecture.
Four resources
Most decisions come down to four costs.
Compute
Where do we perform the work?
build machine
origin
edge
browser
Network
What has to cross the network?
HTML
JSON
JavaScript
component payload
images
Storage / cache
Which result can we compute once and reuse later?
static artifact
CDN cache
fragment cache
data cache
Client CPU
How much work do we leave to the user's device?
parse JS
execute JS
hydrate
render
handle interactions
New rendering techniques usually do not eliminate cost.
They move it between those four places.
SSR is not free
We move compute:
browser -> server
but we increase:
server CPU
request latency
infrastructure complexity
SSG is not free
We move compute:
request-time -> build-time
but we increase:
build cost
storage
invalidation complexity
Hydration is not free
We buy:
early HTML
+
client application
at the cost of:
server render
+
client reconstruction
Islands are not free
We reduce:
global client runtime
but we increase:
number of boundaries
state coordination
runtime fragmentation
Every architecture is an exchange of costs.
The framework should be a consequence of the decisions
That inverts a popular way of designing.
Not:
we use framework X
|
v
so we will use feature Y
but:
this fragment:
- changes rarely
- gets a lot of traffic
- is not personalised
|
v
should be prerendered and cached
And only then do we ask:
how do we implement that in our stack?
Next.js, Nuxt, Astro, SvelteKit or other meta-frameworks are tools for implementing these decisions.
They should not be their source.
The most important question: where to put the boundaries
A senior frontend engineer increasingly rarely optimises an individual:
render()
and increasingly often designs boundaries:
static | dynamic
server | client
cached | fresh
eager | lazy
blocking | streamed
A well-placed boundary can remove whole classes of cost.
A badly placed one can mean that:
personalisation disables the cache for the whole page
one client component pulls a large dependency graph into the browser
one slow fetch blocks the entire SSR
one large hydration boundary occupies the main thread
We did not go back to the server-rendered web
At first glance history has come full circle.
We started with:
server -> HTML
and we are talking about the server a lot again.
But the modern model is different.
Today we can decide separately:
this fragment is produced during the build
this one during the request
this one at the edge
this one we stream
this one stays server-only
this one we activate in the browser
this one we start only after a click
So we did not go back to the old SSR.
We arrived at an architecture in which rendering stopped being a single stage.
It became a problem of placing work across time, space and runtimes.
And that is probably the best way to look at the modern frontend:
not as picking the next shortcut,
but as designing the flow of compute, data and interactivity through the whole system.