This is the story of a team that builds a single-page app, told as a ski hill: four runs, green circle to double black diamond, and something waiting at the bottom. Nobody in it is stupid. Every decision is reasonable on the day it's made. By the bottom of the hill they have written, by hand, a slower and buggier copy of the thing their code was running inside the whole time.
Trail map · Split Brain Mountain
- Green circle · One JSON Endpoint
- Blue square · Router Run
- Black diamond · Split Brain
- Double black · Bundle Bowl
- Chairlift · the browser, which does the climbing
- Past the trees · Yeti · Closed
Who's on the hill
- The TeamFour developers and a designer. Sharp, busy, well-meaning.
- The BrowserThirty years old. Handles navigation, history, caching, scroll, focus, forms and errors. Never speaks. Nobody asks it anything.
- The APISpeaks only JSON. Has no opinion about what a page is.
- The BundleSmall at first.
Green circle: It starts with one JSON endpoint
A list of things, fetched and drawn. Nothing could be more innocent.
The Team has a page that lists orders. It's server-rendered HTML and it's fine, but the designer wants the list to filter without a reload, and somebody says the sentence that starts every one of these stories: "Let's just have the server return JSON and render it on the client."
It takes an afternoon. fetch("/api/orders"), parse, map to rows. The page
feels snappy. There's a spinner now, because for the first time there's a
moment where the page exists but the orders don't. A small price.
Green circle, as drawn on a napkin
Browser tab
- orders.js fetch · parse · draw
- spinner
- empty state
Already in the browser. Unused.
- history
- HTTP cache
- scroll
- focus
- forms
- errors
Server
- /api/orders
- orders table
- fetch()
- JSON.parse
- a spinner
- an empty state
Blue square: Back, new tab, reload and share all stop working
Press Back and lose your place. Cmd-click an order and nothing opens. Reload and start over. Paste the link to a colleague and they see something else.
Filtering works. Then someone filters to "unpaid", clicks an order, presses Back, and lands on the unfiltered list at the top of the page. Two bugs: the filter is gone and the scroll position is gone.
Then the rest of the bugs arrive, and every one of them is a thing people do
with URLs without thinking. Cmd-click or middle-click an order to open it in a new tab: the
order is a <div> with a click handler, not a link, so the browser gets nothing
and the tab never opens. Reload the page: back to the unfiltered list, top of
the page, spinner. Copy the address bar and paste it into chat: the colleague
opens it and sees the default list, because the filter lived in the store, not
the URL. Bookmark it: same. Every one of those worked on the HTML version for
free, and none of them were in the ticket.
They worked because a URL is a name for a thing, and the browser knows what to do with names. That's the oldest idea on the web, and it has a formal version: REST, the architecture the web was reverse-engineered into, starts with "identification of resources," which is a long way of saying every screen gets an address.1 Cmd-click, reload, bookmark, share and Back are all the same operation, "go to this address," and they only work when the address is the truth. The moment the truth moves into a store, the address is a lie the browser hasn't been told about.
The Browser used to handle all of this. It kept the old page in memory and put it back exactly as it was, because one in five navigations on a phone is a Back or a Forward and it was built for that.2 It restored scroll on its own, too; that's the default.3 But there's no navigation any more. There's one page and some state, and the state is wherever The Team last put it.
So The Team writes a router. The filter goes in the query string, then it goes
in a store, then the store syncs to the query string, then the query string
syncs to the store on load. Scroll position gets saved to sessionStorage on
every click. Deep links have to be handled, because a URL somebody pasted into
chat now has to reconstruct a screen from nothing. The <title> has to be set
by hand. Focus has to be moved by hand, because a screen reader was told a page
loaded and nothing happened.
None of this is a feature. Every line of it is a repair.
Blue square, after the back button broke
Browser tab
- orders.js
- spinner
- empty state
- router replaces the address bar
- store filter, list
- query string sync
- scroll save/restore sessionStorage
- deep link handler
- document.title setter
- focus manager
- 404 screen
Already in the browser. Unused.
- history
- HTTP cache
- scroll
- focus
- forms
- errors
Server
- /api/orders
- orders table
- fetch()
- JSON.parse
- a spinner
- an empty state
- a router
- query string sync
- a store
- scroll save/restore
- deep link handling
- document.title
- focus management
- a 404 screen
Black diamond: The page starts showing things that aren't true
You marked it paid. It still says unpaid. Refresh and it's paid. Press Back and it's unpaid again. Support's first answer becomes "try refreshing."
Here's what it looks like from the other side of the screen. The list says three orders are unpaid. A colleague paid one ten minutes ago, and the list still says three, because nothing told it otherwise. You click Pay on another. It flips to paid instantly, which feels great, then a toast says "something went wrong" and it flips back, or doesn't, depending on which bug is on duty. You fill in a note, hit save, and a modal says your session expired; the note is gone. You refresh, and now it says two unpaid, or one, or asks you to log in. Nothing on the screen can be trusted without a reload, and the reload is the thing the app was built to avoid.
The list is now a store, and the store is a copy. That is the whole problem, and it's worth being exact about it: the moment a page keeps its own copy of data the server owns, there are two brains, and they will disagree.
| What happens | Client believes | Server knows |
|---|---|---|
| Page loads, orders fetched | 12 orders, 3 unpaid | 12 orders, 3 unpaid |
| A colleague marks one paid in another tab | 3 unpaid | 2 unpaid |
| User marks another paid; the app updates optimistically | 1 unpaid | 2 unpaid, request in flight |
| The request fails on a bad connection | 1 unpaid, no error shown | 2 unpaid |
| Session expires | 1 unpaid | 401 for everything |
| User presses Back | Whatever was in the store | 2 unpaid |
The Team fixes each row in turn. A cache with a time-to-live. Then invalidation when a mutation succeeds. Then optimistic updates with rollback when one fails. Then a retry queue. Then a refresh-token dance so a 401 doesn't strand the store. Then a websocket, so the other tab's change shows up, which means a reconciliation step when the socket and the store disagree.
The Browser has done all of this since before anyone on The Team was hired. It
asks the server "has this changed?" and gets back a 304 Not Modified with no
body when it hasn't.4 It has no store to reconcile, because it doesn't keep
one. It has the page.
Black diamond, with two brains
Browser tab
- orders.js
- spinner
- empty state
- router
- store the second brain
- query string sync
- scroll save/restore
- deep link handler
- title setter
- focus manager
- 404 screen
- cache + TTLs
- invalidation
- optimistic update
- rollback
- retry queue
- token refresh
- websocket client
- reconciler
- “session expired” modal
Already in the browser. Unused.
- history
- HTTP cache
- scroll
- focus
- forms
- errors
Server
- /api/orders
- /api/refresh
- websocket server
- orders table the first brain
- fetch()
- JSON.parse
- a spinner
- an empty state
- a router
- query string sync
- a store
- scroll save/restore
- deep link handling
- document.title
- focus management
- a 404 screen
- a cache
- TTLs
- invalidation
- optimistic updates
- rollback
- a retry queue
- token refresh
- a websocket
- reconciliation
- "session expired"
Double black diamond: Everything gets slow, and stays slow
A blank page, then a skeleton, then a spinner, then the list. Every visit, every deploy, forever, because the JSON is tiny and the thing that draws it is not.
By now a visitor opening the orders page sees a white screen, then a grey skeleton in the shape of a list, then a spinner, then the list. On a laptop on office wifi that's a second or two and nobody files a bug. On a phone on a train it's ten seconds, or a skeleton that never fills in, or a page that finally loads and then reloads itself because a deploy went out while it was downloading. The page used to appear in one step. Now it has four, and the slow one comes first.
Someone on The Team makes the argument that closes every one of these discussions: "The JSON is 2 KB. The HTML page was 20 KB. We're sending less."
The JSON is 2 KB. The code that turns it into a page is the median JavaScript payload on the mobile web: 558 KB, across 22 requests, of which 206 KB is never executed at all.5 That code is the part of the browser The Team rebuilt, and it ships to every visitor before a single order can be drawn.
The Team knows this, and has an answer: the bundle is cached. Which it is, until the next deploy. Bundles are named by a hash of their contents so that browsers fetch the new one when the code changes, and the change doesn't have to be big. Webpack's own caching guide walks through adding one module and watching the hash change on every bundle, including the vendor bundle that didn't change, and then explains the three configuration steps you need to stop that happening.6 Most teams haven't done those steps. So a typo fix in a component ships the whole thing again, to everyone.
And the page got slower on the way in, not just on repeat. A server-rendered page paints when the HTML arrives. The app paints a spinner when the HTML arrives, then waits for the bundle, then parses and runs it, then fetches the JSON, then draws. The Team measures this eventually and calls the result "perceived performance," which is the phrase for a spinner that appears quickly.
Double black diamond, with the bundle
Browser tab
- vendor chunk 210 KB
- app chunk 180 KB
- route chunks ×9
- hydration
- loading skeleton
- error boundary
- service worker
- hash config
- code splitting
- orders.js
- spinner
- empty state
- router
- store
- query string sync
- scroll save/restore
- deep link handler
- title setter
- focus manager
- 404 screen
- cache + TTLs
- invalidation
- optimistic update
- rollback
- retry queue
- token refresh
- websocket client
- reconciler
- “session expired” modal
Already in the browser. Unused.
- history
- HTTP cache
- scroll
- focus
- forms
- errors
Server
- CDN
- /api/orders
- /api/refresh
- websocket server
- orders table
- fetch()
- JSON.parse
- a spinner
- an empty state
- a router
- query string sync
- a store
- scroll save/restore
- deep link handling
- document.title
- focus management
- a 404 screen
- a cache
- TTLs
- invalidation
- optimistic updates
- rollback
- a retry queue
- token refresh
- a websocket
- reconciliation
- "session expired"
- a bundler
- code splitting
- hash config
- vendor chunks
- a loading skeleton
- error boundaries
- hydration
- a service worker
- "perceived performance"
The lodge: the stories we tell ourselves by the fire
Every one of these has been said with a straight face. I've said most of them myself. Some are even true.
The lodge. Warm, well-argued, and where every SPA decision gets made over a beer.
Some things are applications.
True, and the concession is real. A design tool, a spreadsheet, a map, a video editor: if the user is manipulating a document continuously and the server is a save button, the client should own that state, and a page per view would be absurd. Now count them. Most of what gets built is a list, a form and a detail page, with a login in front. Create, read, update, delete. That's the orders page. It got the spreadsheet's architecture because the spreadsheet's architecture is what the tutorial used.
We need the API for the mobile app anyway.
Maybe. A generic JSON API is one way to build a mobile app, and it's not the only one. Hotwire Native wraps the same server-rendered pages in a native shell, with native navigation on top and the web doing the screens, so the mobile app and the website are one app with one set of bugs.7 And even where a JSON API is the right call for the phone, nothing about it requires the browser to consume it through a store. The server can call the same code path and send HTML. The API and the split brain are separate decisions that got sold as one.
Full page loads feel slow.
They did, in 2012. Today the browser keeps the previous page in memory and restores it instantly on Back.2 It animates between two documents with one CSS rule and no JavaScript.8 Chrome will prefetch or fully prerender the next page from a declarative rules block, though that one is still Chrome-only.9 The feel that justified the rewrite is now a property of documents.
It works offline.
Some of it does, for the price of a service worker, which is on the ball. If offline is a real requirement it's worth it. It usually isn't the requirement; it's the justification found afterwards.
2,000 metres: the yeti
In SkiFree, ski far enough and the abominable snowman comes for you. Here it's the bill for the browser The Team rebuilt.
The bottom of the hill. The snow is grey down here. The ball didn't survive the run, the junk is everywhere, and the yeti has been waiting since the green circle.
Two years in, here is what The Team maintains. A router that almost agrees with the address bar. A store that almost agrees with the database. A cache with its own opinion about what's fresh, a websocket that tells the store it's wrong, a reconciler to settle the argument, a retry queue for when the network settles it instead, a token refresher so the reconciler can keep talking, and a modal for when it can't. None of it was designed. Each piece was stitched on to stop the last one bleeding, and the seams show: three different spinners, two ways to be logged out, and a Back button that works on four screens out of seven. It isn't an application. It's a browser assembled from bug reports.
Line the ball up against the thing it's sitting inside. A router: the address bar. Scroll save and restore: the browser's default. The store and its cache: HTTP caching, with validators the server already sends. Optimistic updates and rollback: a form post, which either works or shows you it didn't. Deep link handling: a URL. Focus management: a page load. The 404 screen: a 404.
Every one of those was there on the green circle, for free, tested against every site on the internet. The Team didn't reject them. The Team never saw them, because the first decision, "the server returns JSON," took the page away, and with it went everything the browser does to a page.
The alternative isn't a rewrite. It's the page, and the page is what spring is for.
The Browser, who has had no lines in this play, was doing all of it the whole time.
Spring: the thaw
The snow melts, the meadow comes back, and it turns out the browser was under there the whole time.
Spring at the base. The same hill, with the snow gone and the ground it was covering.
There's more than one way to get what The Team wanted in the green circle, the list that updates without a full reload, without the three runs that followed. They differ in flavour and share one decision: the server keeps sending HTML. None of them is a framework that hides the browser. Each is a small script that hands the browser more to do.
The two people are most likely to meet are htmx and Hotwire's Turbo. htmx's own description is that it lets you "access modern browser features directly from HTML." You put an attribute on an element, the element makes a request, the server answers with a fragment of HTML, and the fragment is swapped into the page. Links stay links. Forms stay forms. The URL and the history keep working, because the library uses them rather than replacing them.10
Turbo's version of the same idea: "you let the server deliver HTML directly." Turbo Drive follows links and submits forms without a full reload while keeping the browser's history intact. Turbo Frames scope an update to one region of the page, so the orders list can refresh on its own while the rest of the document stands still.11
Neither one reinvents the browser. Neither has a store, because the page is the state. Neither has a router, because the URL is the router. Neither has a cache to reconcile, because the server rendered the truth and the browser cached it the way it caches everything.
This also has a name, and it's older than any of the libraries. The last of
REST's four interface constraints is "hypermedia as the engine of application
state": the server sends a page, and the page carries the links and forms that
say what can happen next.1 A JSON blob that says "status": "unpaid" and
leaves the client to work out what a human can do about it isn't that, whatever
its URLs look like. A page with a Pay button in it is. The filter The Team
wanted in the green circle is a form and one attribute:
<form action="/orders" method="get" hx-boost="true" hx-target="#orders">
<select name="status">
<option>all</option>
<option>unpaid</option>
</select>
<button>Filter</button>
</form>
<div id="orders">
<!-- server-rendered rows; swapped in place on submit -->
</div>
Spring, the same list
Browser tab
- HTML page
- htmx one script tag, 14 KB
Already in the browser. Doing the work.
- history
- HTTP cache
- scroll
- focus
- forms
- errors
Server
- /orders renders the rows
- orders table the only brain
Other runs down the same hill
- Unpoly · progressive enhancement for HTML
- Phoenix LiveView · server-rendered HTML over a socket
- Livewire · the same idea, for Laravel
- Datastar · signals and server-sent HTML
- Stimulus · behaviour on the HTML you already have
- Alpine · sprinkles in the markup, not a browser
Different trails, same base lodge. The page is the state, the URL is the router, the server is the only brain, and the script is small enough to read on a lift.
That's the whole argument of this site in one hill. The browser is not a rendering target. It's a thirty-year-old application platform that already does navigation, history, caching, scroll, focus, forms and errors. Augment it a little and it does the rest. Rebuild it and you'll spend two years on a ball.
-
Roy Fielding, Architectural Styles and the Design of Network-based Software Architectures, chapter 5. REST's four interface constraints are identification of resources, manipulation of resources through representations, self-descriptive messages, and hypermedia as the engine of application state. The web's URLs, links and forms are that last one in practice. ↩
-
web.dev, Back/forward cache. The browser keeps the whole page in memory and restores it instantly on back or forward, state and scroll included. Chrome's figure: one in ten navigations on desktop and one in five on mobile are a back or forward. ↩
-
MDN,
history.scrollRestoration. The default isauto: the browser puts the scroll position back on history navigation. Apps set it tomanualand then do it themselves. ↩ -
MDN, HTTP conditional requests.
ETagandLast-Modifiedvalidators,If-None-Match, and the304 Not Modifiedresponse with no body. Handled by the browser with no developer code. ↩ -
HTTP Archive, Web Almanac 2024, JavaScript. Median mobile page: 558 KB of JavaScript over 22 requests, of which 206 KB, 44% of what was delivered, is unused. ↩
-
webpack, Caching. Output filenames carry a
[contenthash]; the guide adds one module and shows every bundle's hash changing, then prescribesruntimeChunk,splitChunksand deterministic module ids to contain it. ↩ -
Hotwire Native: "build your screens once, in HTML and CSS, and reuse them across every platform." It wraps a web view in native navigation for iOS and Android, with native screens where the web isn't enough. ↩
-
MDN, Using the View Transition API. Cross-document transitions between same-origin pages need one rule in both documents,
@view-transition { navigation: auto; }, and no JavaScript. ↩ -
MDN, Speculation Rules API. Declarative prefetch and prerender of likely next pages. Limited availability: not yet in every major browser. ↩
-
htmx documentation. Attributes such as
hx-get,hx-post,hx-targetandhx-swaplet any element make a request; the server responds with HTML, not JSON;hx-boostandhx-push-urlkeep links, forms and browser history working. It's a dependency-free script added with a single tag. ↩ -
Turbo Handbook: Introduction. Turbo Drive intercepts links and form submissions and loads pages with fetch while maintaining browser history; Turbo Frames scope navigation to segments of a page; Turbo Streams deliver partial updates over WebSocket or SSE. ↩