Most business sites should render HTML on the server
The claim For a marketing site, a product catalogue, a booking flow, or an internal dashboard with a few hundred users, server-rendered HTML with a modest amount of JavaScript will...
The claim
For a marketing site, a product catalogue, a booking flow, or an internal dashboard with a few hundred users, server-rendered HTML with a modest amount of JavaScript will beat a single-page application on the measures your business actually cares about: time to first byte, time to interactive, error rate in the field, and the hours your team spends keeping the thing alive. The single-page architecture solves a real problem. It is usually not your problem.
What a SPA costs before you write any business logic
Start with the floor. React and ReactDOM together land around 45 kB gzipped. Add a router, a data-fetching layer, a form library and a date picker and you are at roughly 120 kB of framework before a single line of your own code ships. A typical mid-sized Canadian business site we are asked to rescue arrives somewhere between 250 kB and 600 kB of gzipped JavaScript.
Measure yours rather than guessing:
find dist/assets -name '*.js' -exec sh -c 'gzip -c "$1" | wc -c' _ {} \; | paste -sd+ | bc
Now do the arithmetic on a real device. A customer on a three-year-old Android phone on rural LTE outside Belleville is not getting 100 Mbps. Assume an effective 1.6 Mbps and 250 kB of JavaScript: that is about 1.25 seconds of transfer. Parsing and compiling that bundle on a mid-tier ARM core adds several hundred milliseconds more, and none of it overlaps with anything useful, because the page is blank until the bundle executes and the first fetch returns.
What server rendering costs
A template render in Django, Rails, Laravel, or Go against a warm database is commonly 8 to 20 ms. Compressed with Brotli, a content-heavy page is 20 to 40 kB. The browser can begin painting on the first packets it receives, because HTML is a streaming format and always has been.
| Path | Bytes before first paint | Round trips before content | Fails if JS errors |
|---|---|---|---|
| Server-rendered HTML | ~30 kB | 1 | No |
| Client-rendered SPA | ~280 kB | 3 (document, bundle, API) | Yes |
The third column is the one that gets underestimated. Every extra round trip costs a full network latency, and latency does not improve when the customer upgrades their plan.
The failure mode nobody budgets for
A server-rendered page that throws an exception in one widget still delivers the rest of the document. A client-rendered page that throws during hydration delivers a white rectangle. We have audited sites where a third-party analytics tag, blocked by a corporate proxy, took the entire checkout offline for anyone behind that proxy. Nobody noticed for eleven days because the server was returning 200 for every request and the dashboards were green.
Where the SPA is genuinely correct
There is a real category of application that should be a SPA: collaborative editors, drawing tools, live trading or dispatch screens, anything with optimistic local state that must survive a flaky connection, and anything where a session lasts forty minutes and involves hundreds of interactions. If your users open the app in the morning and close it at five, amortising a 400 kB bundle over that session is a bargain. If they arrive from a search result, read one page, and fill one form, it never amortises.
The middle path is not a compromise
Render on the server, then add interactivity where it earns its keep. Turbo, htmx, and Alpine each let you keep a single template layer and ship 10 to 15 kB of JavaScript total. Where you need a genuinely rich component — a seating chart, a map, a scheduler — mount it as an island on an otherwise static page and load it lazily.
Pair that with a cache header that lets a CDN absorb the traffic:
Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400
That directive tells the browser to revalidate every time and the edge to serve a cached copy for five minutes, then keep serving a stale copy for up to a day while it refreshes in the background. For a catalogue that changes a few times per week, this moves the overwhelming majority of requests off your origin without any invalidation logic to maintain.
How to decide in an afternoon
- Pull thirty days of analytics and find the median session length and pages per session. Under three pages and under two minutes, you are serving arrivals, not users.
- Count how many distinct pieces of state must be shared across views without a page load. Under five, you do not need a client-side store.
- Ask whether the app must work offline. If not, you do not need a client-side cache either.
Two of three pointing at the server side is enough. The architecture that wins is the one your two-person team can still reason about in eighteen months, and a template that returns HTML is very hard to get wrong.