Konrad Kowalski (rootsher)Principal Platform & Reliability Architect111110010010010010111111010000001110110010010011

The cost of shipping the application to the browser

date
category
Frontend
reading
4 min / 778 words

SPA and CSR solved the interactivity problem.

The browser stopped being just a document renderer and became an application runtime. That made smooth transitions, local state, optimistic updates and a UI that can live for hours without a full reload possible.

But a new cost appeared.

It was no longer only about how much data has to be downloaded.

It was about how much work has to happen before the application becomes usable.

HTML stopped being the final product

On the classic web the server response was almost a finished interface.

text
request
  |
  v
HTML
  |
  v
parse
  |
  v
paint

In a large CSR application the path looks different:

text
request
  |
  v
HTML shell
  |
  v
JavaScript
  |
  v
parse
  |
  v
compile
  |
  v
execute
  |
  v
initialize runtime
  |
  v
fetch data
  |
  v
render
  |
  v
layout
  |
  v
paint

That is a meaningful difference.

The HTML may arrive after 100 ms, and the user still will not see the actual content for another second.

So TTFB stops describing the real user experience.

Bundle size is only part of the problem

For a long time frontend performance was strongly identified with bundle size.

text
main.js - 700 KB

That obviously matters, but transfer size is only the first cost.

The JavaScript still has to be:

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

And executing the code does not happen in a vacuum.

In a typical application the runtime has to:

text
register modules
initialize the framework
create component structures
run effects
build the initial state
perform a render
attach event listeners

On a desktop with a strong CPU this may be practically unnoticeable.

On a weaker phone exactly the same JavaScript can become the main bottleneck.

The main thread is a shared resource

The most important problem is not:

JavaScript is slow.

The problem is:

JavaScript competes for the same main thread with the work needed to display and operate the UI.

Simplified:

text
main thread

JS
style
layout
event handlers
more JS
paint preparation
more JS

If during startup the application performs 300 ms of synchronous work, the browser cannot handle a click at the same time.

The user may already see a button, but the click will only be processed once the main thread can run another task.

That is one of the more important differences between:

content visible

and

application responsive.

A curiosity

A JavaScript engine does not execute source code directly, line by line.

Modern engines first parse the code, create an internal representation of it, generate bytecode, and frequently executed fragments may later reach an optimising JIT.

So the cost of JavaScript does not depend on the number of bytes alone.

Two bundles of similar size can have a different parse, compile and execution cost.

This is one of the reasons why the metric:

text
KB transferred

is not a complete description of an application's cost.

Data fetching can start too late

CSR creates one more problem: dependency discovery.

Assume the browser downloads:

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

Only after executing the JavaScript does the application discover it needs a product:

js
const product = await fetch("/api/products/42");

On the network it looks like this:

text
HTML
  |
  v
discover app.js
  |
  v
request app.js
  |
  v
download
  |
  v
execute
  |
  v
discover /api/products/42
  |
  v
request data

That is a classic waterfall.

The browser could not start fetching the data earlier because it did not know it would be needed.

The more dependencies are discovered only while the application runs, the more sequential the critical path becomes.

Code splitting helps, but changes the problem

The natural answer to large bundles was splitting the application.

Instead of:

text
app.js

we get:

text
runtime.js
vendor.js
dashboard.js
charts.js
editor.js

That lets us avoid downloading code the user does not currently need.

But it raises a new question:

when will the browser find out which chunk it needs?

If only after:

text
main bundle
  |
  v
router
  |
  v
route match
  |
  v
dynamic import

then we have created a waterfall again.

So code splitting does not remove the dependency graph problem.

It only changes its shape.

A commercial example: an online store

Imagine a product page built as pure CSR.

The browser downloads the application shell.

Then:

text
load framework
  |
  v
load router
  |
  v
recognize /products/42
  |
  v
load product chunk
  |
  v
execute component
  |
  v
request product API
  |
  v
receive data
  |
  v
render product

Meanwhile what the user mostly wants to see is:

text
Mechanical Keyboard
129 zł
In stock

That is data the server often already knows during the first request.

Sending an empty shell just so the browser can later ask the server for the information needed to produce the first screen starts to look like an unnecessary round trip.

In a dashboard-style application that cost is acceptable.

In e-commerce, news or a public product catalogue it becomes harder to defend.

The problem was not only performance

CSR also complicated other properties of the web.

If content only appears after JavaScript runs, these become harder:

text
crawlability
link previews
generating metadata
access to content when JS fails
showing content quickly on weak devices

Modern crawlers execute JavaScript far better than they used to, but architecturally there is still a difference between:

text
HTTP response contains content

and:

text
HTTP response contains program that may produce content

The pendulum starts to swing back

By the mid-2010s more and more teams had experience with large SPAs.

The benefits were obvious.

But so were the costs:

text
more JS
-> more startup work

client-only data fetching
-> waterfalls

large runtime
-> more main-thread contention

empty shell
-> later content

The answer was not to abandon the SPA.

It was to change where we perform the first render.

What if the server ran the same component code earlier and sent the browser finished HTML?

The user would get content immediately, and JavaScript could take over the application later.

That way the frontend returns to an idea known since the beginning of the web, but in an entirely new form.

Server-Side Rendering comes back.