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.
request
|
v
HTML
|
v
parse
|
v
paint
In a large CSR application the path looks different:
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.
main.js - 700 KB
That obviously matters, but transfer size is only the first cost.
The JavaScript still has to be:
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:
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:
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:
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:
<div id="app"></div>
<script src="/app.js"></script>
Only after executing the JavaScript does the application discover it needs a product:
const product = await fetch("/api/products/42");
On the network it looks like this:
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:
app.js
we get:
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:
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:
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:
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:
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:
HTTP response contains content
and:
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:
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.