Konrad Kowalski (rootsher)Principal Platform & Reliability Architect100001101111001100111111110111001111010000100110

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:

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

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

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

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

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

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

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

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

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

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

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

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

js
useState()

or:

js
window.addEventListener(...)

or an event handler:

jsx
<button onClick={...}>

then we still need code on the client.

That is why a real application looks more like this:

text
server-heavy tree
  |
  v
client boundaries
  |
  v
interactive subtrees

Not:

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

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

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

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