Server Components: moving the execution boundary
- date
- category
- Frontend
- also in
- Software Architecture
- reading
- 4 min / 796 words
For most of this story we asked:
when do we render?
Then:
where do we render?
Hydration, islands and resumability add another question:
when do we run the code on the client?
Server Components change the level of abstraction.
The question becomes:
should this code reach the browser at all?
A component tree does not have to mean one runtime
The classic component model usually assumes a component is part of an application the client can ultimately execute.
Server Components let you cut the tree:
App
├── ProductPage -> server
│ ├── ProductDetails -> server
│ ├── Recommendations -> server
│ └── AddToCart -> client
└── Navigation -> client
Some components exist only on the server.
Their implementation does not have to be sent to the browser.
That is a meaningful difference from plain SSR.
SSR and Server Components solve different problems
SSR answers:
where is the HTML for the first render produced?
Server Components answer:
in which runtime can a given component execute?
We can have a component rendered on the server during SSR whose code still ships to the browser for hydration.
With Server Components part of the code can be entirely server-only.
SSR:
component code
|
v
server render
|
v
HTML
|
v
same component code ships to client
Server Component:
component code
|
v
server execution
|
v
serialized result
|
v
no client implementation required
That is why equating RSC with SSR is a mistake.
Why do we ship so much code in the first place
Assume a component whose only job is to fetch a product and prepare the data:
async function ProductDetails({ id }) {
const product = await db.products.find(id);
return (
<section>
<h1>{product.name}</h1>
<p>{product.description}</p>
</section>
);
}
If that component needs no:
local state
event handlers
browser APIs
effects
then its implementation exists mainly to produce a render result.
So why ship it to the user?
Server Components let you keep that part of the graph on the server.
A commercial example: a product page
Assume a large product page.
We have:
ProductPage
├── ProductDescription
├── TechnicalSpecs
├── RelatedProducts
├── ReviewsSummary
├── VariantSelector
└── AddToCart
The first four fragments may not need a client runtime.
VariantSelector and AddToCart are interactive.
So we can split the system:
SERVER
├── ProductDescription
├── TechnicalSpecs
├── RelatedProducts
└── ReviewsSummary
CLIENT
├── VariantSelector
└── AddToCart
The browser then receives the code responsible for interaction, not all the code needed to construct the page.
The server/client boundary is a dependency graph boundary
That is the most important technical detail.
Imagine:
ProductPage
|
v
MarkdownRenderer
|
v
syntax-highlighting-library
|
v
large parser
If that whole graph belongs to the client bundle, the browser may receive a lot of code just to render the content once.
If ProductPage is server-only:
server graph
├── MarkdownRenderer
├── parser
└── database client
those dependencies never have to cross the network boundary.
The client receives only the result.
That is why Server Components can be treated as a mechanism for controlling code distribution, not only rendering.
A curiosity
The Server Component -> Client Component boundary requires serialisation.
A Server Component cannot simply pass an arbitrary value to the client.
If we try to pass something like:
{
connection: databaseConnection,
handler: () => doSomething(),
stream: internalServerObject
}
the client runtime has no way to reconstruct those objects.
The network boundary forces a data contract.
In practice that means component architecture starts to face constraints similar to API design:
server value
|
v
serializable representation
|
v
network boundary
|
v
client value
That is a very interesting shift, because a boundary in the UI tree becomes a distributed system boundary at the same time.
It can remove the client-side waterfall
Server Components can also change how data is fetched.
In classic CSR:
browser
|
v
component A loads
|
v
fetch A
|
v
component B discovered
|
v
fetch B
A waterfall appears.
If a server component knows the whole required graph earlier:
server
├── fetch A
├── fetch B
└── render result
part of the work can happen closer to the data and in parallel.
That does not automatically mean no waterfalls: a badly designed server component can still make sequential fetches.
But the place where the dependency graph can be optimised changes.
Server Components do not eliminate client components
If a component needs:
useState()
or:
window.addEventListener(...)
or an event handler:
<button onClick={...}>
then we still need code on the client.
That is why a real application looks more like this:
server-heavy tree
|
v
client boundaries
|
v
interactive subtrees
Not:
everything on server
So the most important decision becomes where to put the boundary.
A boundary too high = too much JavaScript
If we mark a large fragment as client-side:
CLIENT
└── ProductPage
├── Description
├── Specs
├── Reviews
├── Gallery
└── AddToCart
the whole dependency graph below it can start belonging to the client.
If we move the boundary lower:
SERVER
└── ProductPage
├── Description
├── Specs
├── Reviews
├── Gallery
└── CLIENT AddToCart
the browser receives far less code.
That is why "use client" in React Server Components is architecturally more important than it looks.
It does not only describe a component.
It can affect the whole dependency graph below the boundary.
A typical solution: React Server Components
The best-known example is React Server Components, used among others by modern React frameworks.
The implementation uses a dedicated transport format for sending the result of the server component tree along with references to client components.
The browser does not simply receive finished HTML.
It receives a representation the client runtime can use to assemble the final application tree.
That is one of the reasons Server Components are something other than classic template rendering.
The boundaries start to overlap
This is where modern rendering architecture gets interesting.
A single page can use, at the same time:
static generation
+
revalidation
+
server components
+
streaming
+
client components
+
selective hydration
+
edge cache
These mechanisms are not competitors.
They describe different axes of the system.
Which is exactly why the question:
are we using SSR or SSG?
is increasingly too simplistic.
Modern applications do not pick one strategy.
They compose many strategies at once.
That will be the subject of this series' finale.