Konrad Kowalski (rootsher)Principal Platform & Reliability Architect011100110101111101010001110100010010101111101100

SPA and CSR: when the browser took over the application

date
category
Frontend
reading
4 min / 899 words

AJAX solved one specific problem: a request no longer had to mean a full navigation.

The next step was logical.

If the browser can fetch data in the background and update only fragments of the UI, why should the server keep rendering a new document for every transition between screens?

That is how the era of Single Page Applications began.

One document, many views

In the classic model:

text
/products
  |
  v
server render
  |
  v
HTML document

/products/42
  |
  v
server render
  |
  v
another HTML document

In an SPA:

text
initial document
  |
  v
JavaScript application
  |
  v
client-side router
  |
  v
fetch data
  |
  v
render next view

The browser downloads the main document once and then manages the transitions between screens itself.

The URL can still change, but it no longer has to mean downloading a new document.

That changes the role of the frontend fundamentally.

The frontend stops being a document presentation layer.

It becomes a long-lived application runtime.

CSR is not the same thing as SPA

These terms often get thrown into one bag, but they describe different things.

SPA is mostly about the navigation model:

the application runs inside a single document.

CSR is about where rendering happens:

the UI is produced on the client.

The typical model looks like this:

text
GET /
  |
  v
minimal HTML
  |
  v
download JavaScript
  |
  v
execute application
  |
  v
fetch data
  |
  v
render UI

The server no longer has to generate a finished product view.

It can return a shell:

html
<div id="app"></div>
<script src="/app.js"></script>

And the rest happens on the client.

Why this was attractive

For applications like mail, a CRM, a dashboard or a document editor, the document model starts to get uncomfortable.

Imagine an analytics system.

The user:

text
changes the date range
opens a modal
filters a table
switches a tab
expands a chart
selects records
refreshes data in the background

A full navigation after each of those operations would be absurd.

An SPA lets you keep:

text
application state
component state
cached data
open connections
current UI state

for the whole session.

That is exactly why CSR still makes a lot of sense in many applications behind a login.

For an internal admin panel, the time to first HTML may matter less than the smoothness of the next few hundred interactions.

The server changes role

In this model the backend increasingly stops returning views.

It returns data:

text
GET /api/products/42
  |
  v
{
  "id": 42,
  "name": "Mechanical Keyboard",
  "price": 129
}

And the browser does:

text
data
  |
  v
application state
  |
  v
render
  |
  v
DOM

This leads to a very important separation:

text
backend
  -> data + business logic

frontend
  -> application state + rendering

In practice the line is not always that clean, but this is when the API + frontend application model starts to settle in.

The problem of syncing the DOM by hand

In a larger application a hard problem shows up very quickly.

You have state:

js
{
  user,
  cart,
  filters,
  products,
  selectedProduct,
  notifications
}

And a DOM that is supposed to be its current representation.

If you update the DOM manually, you have to watch every dependency.

text
state changes
  |
  v
which elements are affected?
  |
  v
which listeners survive?
  |
  v
which derived values change?
  |
  v
which DOM nodes need replacement?

This is where frontend frameworks start to grow.

Not because document.querySelector() is too hard.

The problem is keeping consistency between changing state and a large UI tree.

React will later describe the idea very elegantly:

text
UI = f(state)

Instead of telling the browser exactly which elements to change, you describe what the UI should look like for a given state.

The framework takes the synchronisation on itself.

A curiosity

A client-side router does not "navigate" in the same sense the browser does.

When you run:

js
history.pushState({}, "", "/products/42");

the browser changes the URL and the history, but does not automatically fetch a new document.

It is up to the application to interpret the new URL:

text
URL changed
  |
  v
router matches route
  |
  v
load code/data
  |
  v
render view

That looks like a small detail, but it means a huge shift in responsibility.

In classic navigation the browser owns the protocol of the whole process.

In an SPA a large part of that lifecycle becomes application code.

The cost: the browser gets more and more work

CSR gives a great interaction model, but it moves a significant part of the cost onto the user's device.

A typical first-render path can look like this:

text
HTML
  |
  v
discover JS
  |
  v
download JS
  |
  v
parse
  |
  v
compile
  |
  v
execute
  |
  v
initialize framework
  |
  v
render
  |
  v
discover required data
  |
  v
request data
  |
  v
render again

And this is where a problem appears that is easy to miss at first.

The network may deliver the document very quickly, and the user is still looking at an empty shell.

Download is only part of the JavaScript cost

Frontend developers spent a long time looking mainly at bundle size.

For example:

text
app.js - 400 KB

But 400 KB of JavaScript costs more than the download alone.

The browser has to go through further stages:

text
download
  |
  v
decompress
  |
  v
parse
  |
  v
compile
  |
  v
execute

On a powerful laptop this may be almost invisible.

On a weaker phone the difference becomes significant.

On top of that, JavaScript competes for the main thread with:

text
input
layout
paint
other tasks

That is why two pages with identical transfer size can have completely different startup performance.

CSR can also create a waterfall

Assume the HTML contains only:

html
<div id="app"></div>
<script src="/app.js"></script>

The browser does not yet know that the application needs /api/products.

Finding that out looks like this:

text
HTML
  |
  v
app.js
  |
  v
execute
  |
  v
discover data dependency
  |
  v
/api/products

Every arrow can mean more latency.

The later we discover a dependency, the later we can start fetching it.

This will later be one of the reasons for moving part of the rendering back to the server.

Commercially CSR still makes a lot of sense

Imagine a tool like Figma, Notion or a rich trading terminal.

Once inside the application, a user may work for an hour.

In such a system the initial startup cost is acceptable if what you get afterwards is:

text
instant transitions between views
local state
optimistic updates
background synchronization
heavy interactivity without navigation

So CSR is not "the old model that SSR replaced".

It is still a sensible architecture for a specific class of applications.

The problem shows up when we start applying it everywhere.

A landing page, an article, a product listing and an admin panel do not share the same properties.

And around the mid-2010s the web started to feel the cost of the approach more and more:

text
ship everything
-> execute everything
-> render everything
-> then show content

That is when the pendulum started swinging back towards the server.

Not to kill the SPA.

To recover one thing the classic web had from the very beginning:

HTML available before the JavaScript application has a chance to start.