hypermedia.lol

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

Trail map: four runs from the summit, a chairlift, the lodge at the base and a yeti past the trees One JSON Endpoint Router Run Split Brain Bundle Bowl THE LODGE YOU ARE HERE SUMMIT · 2,000 m TRAIL CLOSED · YETI

Who's on the hill

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
GET /api/ordersJSON · 2 KB

Server

  • /api/orders
  • orders table
Green circle. One script, one endpoint, two small extras. Six things the browser does for free are already idle, one layer down.
The snowball, bottom of the green run. Four things the HTML version didn't need. Each one is tiny.

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
GET /api/orders?status=unpaidJSON · 2 KB

Server

  • /api/orders
  • orders table
Blue square. Every red box is a repair of something in the grey row beneath it. The grey row still works. Nobody is calling it.
Bottom of the blue. The Team has reimplemented the address bar, the back button and scroll restoration. All three are worse than the originals, and the originals are still there, unused, one layer down.

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 happensClient believesServer knows
Page loads, orders fetched12 orders, 3 unpaid12 orders, 3 unpaid
A colleague marks one paid in another tab3 unpaid2 unpaid
User marks another paid; the app updates optimistically1 unpaid2 unpaid, request in flight
The request fails on a bad connection1 unpaid, no error shown2 unpaid
Session expires1 unpaid401 for everything
User presses BackWhatever was in the store2 unpaid
The split brain. Each row is a real bug The Team will file, and each fix is more code: revalidation, rollback, retry, token refresh, a "your session has expired" modal. None of these rows exist when the server renders the page, because there is one brain and the page is a photograph of it.

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
GET /api/orders · PATCH /api/orders/:idJSON · 304 never happens
subscribeorder.updated

Server

  • /api/orders
  • /api/refresh
  • websocket server
  • orders table the first brain
Black diamond. Two copies of the truth, a second wire to keep them talking, and a client-side HTTP cache that can't see the real one.
Bottom of the black diamond. The Team is now maintaining an HTTP cache. It is not as good as the one in the browser, and it has to be, because the browser's can't see inside the store.

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.

Server-rendered page, unchanged elsewhere20 KB
Same page, browser already has it304, 0 KB
The JSON everyone quotes2 KB
Median JavaScript, re-fetched because the hash changed558 KB
What a one-line fix costs each visitor, to scale. The server-rendered page costs one small document, or nothing if the browser can validate its copy. The app costs the bundle, because the fix is in the bundle. The 2 KB was never the payload.

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
GET /static/app.3f9c1e.jsJavaScript · 558 KB, every deploy
GET /api/ordersJSON · 2 KB
subscribeorder.updated

Server

  • CDN
  • /api/orders
  • /api/refresh
  • websocket server
  • orders table
Double black. The 2 KB everyone quotes is the thin wire in the middle. The 558 KB is the browser The Team rebuilt, shipped to every visitor, every deploy.
Double black. Everything on the ball is a thing the browser underneath it already did. The Team is proud of the ball. It took two years.

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>
The green circle, done in spring. A form that works with no script at all, boosted so the response replaces one region instead of the page. Same server, same HTML, no second brain.

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
GET /orders?status=unpaidHTML fragment · 304 when unchanged

Server

  • /orders renders the rows
  • orders table the only brain
Spring. The state lives on the server, the browser does the navigating, caching, scrolling and focusing it was built for, and the app layer is one attribute and one script tag.

Other runs down the same hill

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.


  1. 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. 

  2. 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. 

  3. MDN, history.scrollRestoration. The default is auto: the browser puts the scroll position back on history navigation. Apps set it to manual and then do it themselves. 

  4. MDN, HTTP conditional requests. ETag and Last-Modified validators, If-None-Match, and the 304 Not Modified response with no body. Handled by the browser with no developer code. 

  5. 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. 

  6. webpack, Caching. Output filenames carry a [contenthash]; the guide adds one module and shows every bundle's hash changing, then prescribes runtimeChunk, splitChunks and deterministic module ids to contain it. 

  7. 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. 

  8. 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. 

  9. MDN, Speculation Rules API. Declarative prefetch and prerender of likely next pages. Limited availability: not yet in every major browser. 

  10. htmx documentation. Attributes such as hx-get, hx-post, hx-target and hx-swap let any element make a request; the server responds with HTML, not JSON; hx-boost and hx-push-url keep links, forms and browser history working. It's a dependency-free script added with a single tag. 

  11. 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.