← the whole book on one page

Chapter 8: UI and Frontend Architecture

Users never see your architecture. They see the pixels it earned them.

By this point Encore has extracted services, an event backbone and a hardened API. Yet picture a fan on a phone in a stadium parking lot, one bar of signal, waiting four seconds for the seat map to appear. That fan cannot see the services, the events or the API. What the fan sees is a blank screen, and from where that fan sits, none of our backend work has happened at all. This is the uncomfortable truth with which every frontend conversation begins: the user judges the whole system by the part that reaches the screen.

That is why the frontend deserves to be treated as an architectural domain in its own right, not as a coat of paint applied at the end. Every characteristic Chapter 1 promised (elasticity, availability, speed) is finally experienced in a browser or an app, so this is where those promises are kept or broken. The frontend also has concerns of its own that the backend never had to face: the cost of turning data into pixels on someone else’s device, the question of how many teams can build one screen together, the state that lives in a tab the user may close at any moment, and a security perimeter that runs through a browser we do not control. The habits we formed on the backend map onto these problems badly, so we have to reason about them afresh.

It helps to remember that our field has been arguing about where the work belongs for half a century, swinging back and forth between thin clients and thick ones. Terminals displayed whatever a central machine sent them. Client-server desktops took the logic home to the user’s own computer. The early web made the browser a thin terminal again, rendering pages the server had assembled. The single-page application made it thick once more, shipping an entire program to the browser. And today’s frameworks are carrying rendering back to the server, a little sheepishly, under new names. None of these swings was a mistake, and none was final. Each answered the economics of its moment: what computing cost, how fast the network was, how hard software was to deploy and update, and what users had come to expect. When those conditions changed, the balance tipped again.

The lesson I take from that history is to look past the framework of the day to the decisions underneath it. Frameworks change every few years; the decisions last much longer. This chapter takes four of them in turn: where rendering happens, how frontend code scales as more teams work on it, where the frontend meets the backend, and what AI features do to the interface. Get those right for Encore, and the next framework becomes a choice of tools rather than a change of architecture.

8.1 Rendering Architectures

Choosing a rendering strategy

Before a fan sees anything, someone has to build the HTML for the page, and the first architectural question of the frontend is simply where that work happens. There are only three places it can happen: in the fan’s browser, on a server at the moment the fan asks, or ahead of time, before anyone has asked at all. Each family of techniques picks one of those places, or a mixture. Client-side rendering (CSR) does the work in the browser; it is the model behind the single-page application (SPA), which downloads a bundle of JavaScript and then builds every screen itself. Server-side rendering (SSR) builds the page on a server for each request and sends finished HTML. Static site generation (SSG) builds every page once, at build time, and serves the same files to everyone. Incremental static regeneration (ISR) and its newer relatives sit between those poles: they serve prebuilt pages but rebuild them in the background when they go stale. Figure 8.1 lays out the whole menu with what each option costs (“SEO” in the table is search-engine optimization, the business of being found and ranked by search engines, which reward pages that arrive as complete HTML).

Strategy HTML assembled First paint Freshness Server cost Fits
CSR (SPA) in the browser slow (bundle first) live tiny logged-in tools, editors
SSR per request fast live per-request personalized, SEO-critical pages
SSG at build time fastest stale until rebuild ~zero marketing, docs
ISR / SSG+revalidate build + timed or on-demand (tag) revalidation fastest seconds-to-minutes stale tiny catalogs, listings
Streaming SSR / server components server renders and streams; client JS only for interactive parts fast, progressive live per-request app-like pages that still need fast first paint
Islands / partial hydration server, JS only where needed fast mixed small content pages with pockets of app
Edge SSR per request, near the user fast globally live per-request personalized + global

Figure 8.1 — The rendering menu, from client-side rendering to edge SSR, with when each strategy assembles the HTML, how fast and how fresh the result is, what it costs to serve and where it fits. Rendering is chosen per page class, the way Chapter 4 chose consistency per invariant; no single row should cover a whole product. (ISR is Next.js’s name for the idea; other frameworks ship it under other names.)

The table rewards a slow reading, because each column is a trade against another. “First paint” is how soon the fan sees something useful. CSR is slow here because the browser must download and run the JavaScript bundle before it can draw anything, while every server-side or prebuilt option sends HTML the browser can show at once. “Freshness” is how current that content is: a page built per request is live, and a page built last Tuesday is as old as last Tuesday. “Server cost” is what we pay to produce each view; prebuilt files cost almost nothing to serve, while per-request rendering costs compute on every visit. The lower rows are attempts to have more than one thing at once. Streaming SSR sends the page in pieces as the server finishes them, so the top of the page appears before the slow parts are ready, and server components keep most of the rendering on the server and ship JavaScript only for the parts a fan can click. Islands take the same idea from the other side: the page is static HTML with a few interactive “islands” that are hydrated, meaning the browser downloads just enough JavaScript to attach behavior to those regions and leaves the rest alone. Edge SSR renders per request, but on servers spread around the world close to each user, rather than in one central location.

A restaurant kitchen faces the same choices, and runs on the same logic. Some dishes are prepared the night before and simply plated, which is static generation: fast and cheap, but only as fresh as last night. Some are cooked to order, which is server rendering: fresh and personal, but every plate costs the cook’s time. A few restaurants hand the guest a hot pot and raw ingredients and let them cook at the table, which is client-side rendering: the kitchen does little, but the guest waits and works. A good kitchen makes a stock in the morning and tops it up through the evening, which is roughly what ISR does. No sensible kitchen does everything one way. Which dish goes to which station depends on two questions: how often the dish changes, and how much the guest cares that it is fresh. The analogy breaks in one useful place. In a kitchen, the guests do not affect each other’s dishes, but on the web a million fans may ask for the same page in the same minute, and that is exactly when the prepared-in-advance station earns its keep.

Encore uses three rows of the table, one for each kind of page that matters most. The event catalog is read by millions of fans and changes about once a week, so it is ISR: pages are prebuilt and served cheaply, then rebuilt in the background when they go stale, and the few minutes of staleness that implies are the same staleness Section 4.3 already accepted for the catalog. The on-sale seat map is harder, because it combines three kinds of data that age at very different rates. The venue’s geometry (where the rows and sections are) is public and barely changes; seat availability changes every second during an on-sale; and the fan’s own selection is personal and exists only in that fan’s session. So the seat map is CSR inside an SSR shell. The server sends a page frame, the shell, that appears quickly and carries the layout, and inside it the browser renders the live map, fed by the real-time updates described in Section 8.4. Checkout is SSR with minimal JavaScript. Since checkout is personalized and nobody needs to find it through a search engine, it might have been CSR, and many teams would build it that way. But checkout is where the money moves, and that changes the calculation. A server-rendered form ships less code to a fan’s weak phone, has fewer moving parts that can fail, and still submits even if the JavaScript bundle never finishes loading on a congested mobile network during an on-sale. Near the money, the dull technology is the brave choice.

All three rows sit behind the content delivery network (CDN), the worldwide network of caching servers that hold copies of content close to users. The origin is Encore’s own infrastructure where the authoritative data lives; the edge is the CDN’s servers near each fan. Static acceleration, meaning serving cached files from the edge, is nearly free: the ISR catalog pages and every JavaScript bundle are cached near every fan, so a fan in Lisbon never waits for a round trip to Encore’s data centers to see the catalog. Rendering at the edge is a different matter, and it pays off only when the data the page needs can also be read at the edge. Suppose we rendered the seat map in an edge function in Lisbon. To draw availability it would have to ask the origin for current seat state on every request, so the fan still waits for the long trip to the origin, plus the extra step through the edge function in front of it. We would not have removed the distance, only added a hop to it. So Encore’s rule is to cache at the edge aggressively, compute there sparingly, and keep authoritative seat state at the origin, where the seat ledger lives.

Core Web Vitals and performance budgets

Perceived speed was studied long before the web, and it is worth remembering why. Robert Miller’s 1968 paper on response time in conversational computing asked how long a person will wait for a machine before the “conversation” between them breaks down, and the person stops thinking about the task and starts thinking about the wait. The machines have changed beyond recognition since then; the people have barely changed at all, which is why the question still applies to a fan staring at a phone. Today Google’s answer is its Core Web Vitals, three measures that cover loading speed, interactivity and visual stability. LCP (largest contentful paint) is the moment the page’s main content, such as the hero image or the seat map, appears on screen. INP (interaction to next paint) measures how quickly the page visibly responds after the user taps or types; it replaced first input delay as a Core Web Vital in 2024. CLS (cumulative layout shift) measures how much the content jumps around while the page loads, the effect that makes a fan tap the wrong button because an image arrived and pushed it down. Google rates a page “good” when its 75th-percentile visit reaches LCP ≤ 2.5 s, INP ≤ 200 ms and CLS ≤ 0.1. The 75th percentile, written p75, means that three visits in four must be at least that good, so a fast experience on the developers’ own laptops is not enough to pass.

These vitals are the characteristics of Chapter 1, dressed for the browser, and they have the three properties that made those characteristics useful. They are measurable, as we just saw. They are tied to revenue: vendor and retailer studies repeatedly report that added latency loses conversions, though the figures vary widely from study to study. And they are budgetable, which is what makes them architectural rather than merely desirable. A performance budget is a limit we write down and enforce, and in continuous integration (CI) budgets serve as the fitness functions for these characteristics, the automated checks that fail when a characteristic slips. At Encore, the build fails when the checkout bundle exceeds 150 KB of compressed JavaScript, or when LCP regresses past 2 s on a mid-tier phone profile.

The reason to automate this is the way performance is actually lost. It is rarely lost in one dramatic decision that someone could have argued against in a meeting. It is lost one dependency at a time: a date library here, an analytics script there, each one defensible on its own and each adding a few kilobytes. No single change looks like the one that made checkout slow, so without a budget nobody notices until fans do. A budget turns that slow drift into a red build on the day the drift happens, while the change is small and its author still remembers why they made it. Budgets have a limit, though: CI measures in the lab, on a simulated phone over a simulated network, so it catches regressions but cannot tell us what fans really experienced. For that, Encore uses real-user monitoring, which collects the vitals from fans’ actual browsers and reports them at p75 on phones. The lab tells us when we made things worse; the field tells us what fans actually got.

Recap. Every page must be assembled somewhere, and no single rendering strategy suits a whole product, so we choose per page class by asking how often the content changes and how fresh it must be. Encore runs three strategies side by side: ISR for the catalog, CSR inside an SSR shell for the seat map, and SSR for checkout, all behind a CDN that caches at the edge but leaves seat state at the origin. Core Web Vitals are architectural characteristics, and performance budgets in CI are the fitness functions that stop them eroding one dependency at a time, with real-user monitoring showing what fans actually experience.

Exercise 8.1. Pick a product you know well and list five of its screens. For each screen, write down which row of Figure 8.1 it should use, and why, in terms of how often its content changes, how fresh it must be and whether search matters. Then identify the screen that you believe uses the wrong row today, and describe what that mismatch costs: slower first paint, stale content, higher server bills or lost search traffic.

8.2 Structuring the Frontend

Structuring the codebase for teams

Rendering decides where a page is assembled; structure decides who can change it without tripping over whom. A frontend codebase fails at the same moment a backend one does: not when it grows large, but when several teams must work in it at once, each with its own priorities and release dates. One team can keep a messy codebase in its head. Three teams cannot, because each of them now changes code the others depend on, and every unexpected break costs a conversation. The structural tools that help are the ones Chapter 2 gave us for the backend monolith, applied to screens instead of services.

The first is the design system: the shared tokens (colors, spacing, type sizes), components (buttons, form fields, dialogs) and patterns (how a form reports an error, how a list loads) that make Encore’s storefront feel like one product rather than several teams’ products stitched together. A design system is a real artifact with an owner and a versioning policy, and it plays the part that domain-driven design calls a shared kernel: the small body of code that several teams agree to share, and to change only by agreement, because consistency is worth more to them than independence. Every Encore team accepts this one dependency so that a fan sees the same button, the same seat-price format and the same error message on every screen.

It is also a small pattern language for screens, in Christopher Alexander’s sense (Chapter 15), with one important difference. Alexander’s patterns were adapted by each builder on site, to fit the particular house and the particular hillside. A design system is owned centrally, and so it inherits a central owner’s queue: every request for a new variant waits until the owning team has time for it. That is why Chapter 3’s rule applies: keep the kernel thin. A shared button, date picker or price label changes rarely and serves everyone. A shared “seat-selection panel” or “checkout summary” is business logic dressed as a component, and it changes whenever the business does. Once the design system absorbs components like these, the checkout team cannot ship a change to its own flow until the design-system owners review and release it, and the kernel has become a committee that every team must petition.

The second tool is the module boundary, and here the choice is what to sort the code by. A common default is to sort by technical layer: one folder for components, one for state, one for API calls. Encore instead sorts by bounded context: catalog/, checkout/ and organizer/, each folder owning its own routes, state and API calls. The difference shows up the first time something changes. Suppose Encore adds a service fee to checkout. In a layered codebase, that one business change touches the components folder, the state folder and the API folder, each shared with every other team. In a context-sorted codebase, it touches checkout/ and nothing else. Layer folders sort code by what it is; context folders sort it by why it changes, and grouping things that change for the same reason is exactly Parnas’s criterion from Chapter 1.

A boundary only holds if something checks it, because under deadline pressure a quick import from checkout/ into catalog/ always looks harmless, and a boundary nobody checks is only a suggestion. Encore enforces its module boundaries with the same cycle-detection fitness functions that guarded the Chapter 2 monolith: an automated check in the build that fails when two contexts come to depend on each other in a cycle, so that neither can change without the other. A monorepo, one repository holding all the frontend code, makes this enforcement cheap, since a single build sees every import at once. The catch is that the monorepo’s build tooling then becomes shared infrastructure in its own right, and it needs an owner just as the design system does; otherwise it slows down for everyone and is fixed by no one.

Frontend state

Once the code is sorted, the next source of trouble is the data it holds while the page is running, its state. I am convinced that most frontend complexity comes from handling state without a taxonomy, that is, without first asking what kind of state each piece of data is. Much of that complexity is accidental in Brooks’s sense (Chapter 1): it is not inherent in the problem, but produced by treating very different kinds of data as if they were one kind. There are three kinds, and each has its own machinery:

Kind Examples Right machinery
Local UI state open modal, input draft component state; nothing fancier
Server cache events, seats, orders (the backend’s data, borrowed) query cache with a staleness policy (stale-while-revalidate: show the cached copy at once, refetch in the background)
Genuine global client state auth session, cart-in-progress, theme small explicit store

Figure 8.2 — The state taxonomy: local UI state, server cache and genuine global client state, each with its own machinery. The classic failure is one global store holding all three: server data goes stale because nothing refreshes it, UI details churn the store with constant updates, and every component ends up coupled to every other. Most “global state” is really server cache, governed by Chapter 4’s caching rules and staleness policy.

Each row deserves a word. Local UI state belongs to one component and dies with it: whether the seat-map legend is expanded, or what a fan has typed into the search box so far. Nothing else needs to know, so the component’s own state is enough. Genuine global client state is the small set of facts that many parts of the page need and that exist only in the browser, such as who is signed in, what is in the cart before checkout, or whether the fan chose dark mode. A small, explicit store suits it. The trouble comes when a team reaches for that store for everything, and the classic failure follows: the store fills with server data that nothing refreshes, every keystroke in a draft triggers updates across the page, and because every component reads from the same place, no component can be changed without checking all the others.

The middle row is where the confusion lives. The events, seats and orders a frontend holds are not its own data; they are borrowed copies of data owned by Encore’s backend services, and they begin to age the moment they arrive. A seat shown as available may have been sold a second later. Once we see the data that way, the right questions are the ones Chapter 4 asked of every cache: how stale may this copy be, and who tells us when the original changed? A query cache answers them directly. It keys each piece of server data by the request that fetched it, records how old it is, and applies a staleness policy, typically stale-while-revalidate: show the fan the cached copy at once, fetch a fresh one in the background, and update the screen when it arrives. Much hand-built synchronization code, all the effects and flags that try to keep a global store in step with the server, turns out to have been a cache that did not know its own name. Dijkstra’s separation of concerns does the rest. Kept apart, each kind of state is simple enough to reason about on its own; mixed together, each kind’s problems leak into the others.

The same habit of hiding things inside components produces the frontend’s characteristic latency problem, the request waterfall. A page component renders and only then fetches its data; when the data arrives, it renders a child component, which only then starts its own fetch; and that child’s child does the same. Each request waits for the one before it, so the page’s load time is the sum of all the round trips rather than the longest of them. This is Chapter 4’s sequential-call latency, the same cost as a service that calls three others one after another, hidden inside component code where it is hard to see. The fixes are the same too. Declare each page’s data needs at the route level, so the router knows everything the page will need before any component renders. Fetch those independent requests in parallel. And prefetch on intent: when a fan hovers over an event or scrolls it into view, start fetching its seat map before the click.

The lesson of the waterfall is that a frontend can be slow without any one component being slow. Each component, examined on its own, fetches once and renders promptly; the slowness lives in how they are arranged. Encore’s four-second seat map had two causes, and neither was visible from inside any component: a three-deep waterfall, in which each level waited for the one above it, and an unbudgeted JavaScript bundle that no one had been measuring against a limit.

Recap. The design system is the frontend’s thin shared kernel, and it must stay free of business components or it becomes a committee. Module boundaries follow bounded contexts rather than technical layers, and automated checks enforce them. State falls into three kinds, local UI, server cache and genuine global client state, and server cache is handled as caching, with an explicit staleness policy. Waterfalls are sequential network calls that component code hides from its authors, and the fix is to declare data needs per route, fetch in parallel and prefetch on intent.

Exercise 8.2. Pick your product’s slowest screen and trace its network requests, for example in the browser’s developer tools. Draw the waterfall: each request as a bar on a timeline, showing which requests wait for others to finish. Then mark which fetches could run in parallel or be prefetched, and write down the route-level data declaration that would make that happen.

8.3 Micro-Frontends and Composition

Deciding on micro-frontends

Micro-frontends split one frontend into pieces that different teams build and deploy on their own: the seat map ships on one team’s schedule, checkout on another’s, and neither waits for the other. This is the microservices argument of Chapter 5 moved one layer up, toward the user, and it comes with the same prerequisite. Independent deployment is worth its cost only when teams already lose time to one another: one team’s finished work sits idle while it waits for another team’s release, or changes bound for a shared release collide often enough that people plan around them. The problem being solved is waiting, so waiting is what we should measure. A company with one frontend team has nobody to wait for, and for it micro-frontends are pure cost. Encore has nine teams, and only two of them do most of their work in the frontend. That doesn’t clear the bar. Two teams can coordinate one release without much pain, so a single frontend, structured as a modular monolith with Section 8.2’s context boundaries and shipped in one deploy, serves Encore well. Before advising anything else, I would want evidence: release collisions counted over a quarter, showing how often one team’s change was held back by another’s.

There is one exception, and it shows where splitting does make sense: the organizer portal. The people who use it, the release schedule it needs and the risk it can tolerate all differ from the fan storefront. Organizers are professionals who set up events and check sales figures during working hours, while fans arrive in a crowd, all at once, the moment an on-sale opens. A change that is routine for the portal could be reckless for the storefront on an on-sale night. So the portal ships as a separate application, built on the same design system so that both still look and feel like Encore. This is not one frontend cut into pieces; it is two applications, split where two user experiences were already separate. That is the easiest kind of split to defend, because we did not invent the seam; we found it. A city works the same way. Its residential and industrial districts share roads and building codes, yet nobody pretends they are one neighborhood, because the people in them live by different rhythms. The analogy also marks the limit: a city does not split a single street down the middle, and we should not split a single experience either.

Suppose, though, that a company really does need one frontend assembled from pieces that deploy independently. There are four ways to put the pieces together, and each trades independence against integration differently.

None of this is new. The portal servers of the early 2000s assembled pages from portlets, small panels each developed by a different team, and they ran into the same two problems: a page whose panels did not quite match, and pages weighed down by code each panel brought along. The technology has changed several times since, and the problems have stayed. That tells us they belong to composition itself, not to any one tool. Any time we build a page from pieces made apart, we have to work to make them fit together, and no framework does that work for us.

The three runtime options (federation, iframes and edge-side composition) share three costs that a monolith frontend never pays. First, payloads duplicate. Each piece tends to bring its own copy of its framework and libraries, so a fan’s phone downloads the same code two or three times, which works against the performance budgets of Section 8.1. Second, look and behavior drift. In a monolith, one codebase and one review keep buttons, spacing and error messages consistent. Across independent pieces, nothing technical enforces that, so the pieces match only as long as their teams keep choosing to match each other. Third, someone must build and run the shell, the outer application that owns routing, authentication and the composition of the pieces. The shell is not a folder in the codebase that whoever is nearby looks after. It is a product in its own right, with users (the other teams), an owner and a roadmap. Chapter 15 gives this kind of product its proper name: the platform.

The distributed monolith, browser edition. Splitting the code does not split the coupling. If micro-frontends must still release together, because they share the shape of a data structure, depend on each other’s routes or read and write the same Redux store, then a change in one forces a coordinated change in the others. Such a system has the release coupling of a monolith and the duplicated payload of a federation, the costs of both and the benefits of neither. Apply the Chapter 5 test: can this piece ship alone, unannounced?

Recap. Micro-frontends pay off only when coordinating teams already makes them wait on one another, and they split best where user experiences are already separate, as Encore’s organizer portal is from its fan storefront. Composing a single frontend at runtime costs duplicated payload and drifting consistency, and it requires a shell that is owned like a product. Most products are best served by a modular monolith frontend with at most one split.

Exercise 8.3. Count the frontend teams that change your main application, and count how many times last quarter one team’s release was delayed or blocked by another’s. From those two numbers, choose a modular monolith, one split into separate applications, or runtime federation, and name the split line: where the cut would fall and which user experiences sit on either side of it.

8.4 The Frontend–Backend Seam

Backends for frontends

Every frontend eventually meets the backend somewhere, and where that meeting happens is an architectural decision whether we make it on purpose or not. Chapter 5 warned against the screen that calls nine services. Picture the fan opening an event page on a phone: the app asks Catalog for the concert, then Inventory for the seat map, then Orders for the tickets the fan already holds, and so on down a list of services. Each call waits on the one before it, so the delays stack up like a waterfall, and every round trip crosses a mobile network that was slow to begin with. The standard fix is the backend-for-frontend (BFF): a thin aggregation layer, one per experience, that sits between the client and the services. Each experience gets its own: the fan app, the organizer web console, or a partner embed if Encore ever offers one, and each BFF is owned by the team that builds that frontend. The BFF does three jobs. First, it calls the services in parallel on the server side, close to them and over a fast network, so the client makes one request and sees no waterfall. Second, it shapes each response to fit one screen, so the phone receives exactly the fields the seat map needs rather than several services’ worth of general-purpose data. Third, it holds the access tokens for those services, so the browser never has to keep them where script could read them; Section 8.5 explains why that custody matters.

BFFs per experience The fan app calls its own Fan BFF, owned by the fan-web team, which fans out to Catalog, Inventory and Orders; the organizer web app calls the Organizer BFF, which uses Catalog and Analytics; Inventory's seat events reach the fan app through a realtime SSE edge. SEAT EVENTS SSE · SEAT UPDATES REQUESTS REQUESTS Fan app Organizer web Real-time edge (SSE) Fan BFF owned by fan-web team Organizer BFF Inventory Orders Catalog Analytics Experience BFF · frontend-owned Service Call Pushed event

Figure 8.3 — One BFF per experience (here the fan app and the organizer web), each owned by its frontend team because it changes at the pace of that team’s screens. A shared “general-purpose BFF” is really an API gateway slowly accumulating business logic: Chapter 7’s enterprise service bus (ESB) trap, rebuilt one layer up. Seat updates reach the browser through a thin real-time edge, never straight from Inventory.

It is tempting to build a single BFF and let every experience share it. That looks like reuse, and reuse sounds like virtue. The trouble is what happens next. The fan app needs a field added for a new screen, so a line of fan-specific logic goes in. The organizer console needs a sales report shaped a particular way, so organizer logic goes in too. A partner embed, if one arrives, needs its own rules about which events are visible, and in they go. Each change is small and reasonable, but the shared layer now holds everyone’s business logic, every team must coordinate to change it, and no one owns it outright. We have seen this film before: it is Chapter 2’s service-oriented architecture (SOA) story in miniature, where a shared integration layer grew until it was the hardest part of the system to change. The safeguard is a simple test of size. An aggregator should be thin enough that the team that owns it could rewrite it in a week. If it would take longer, logic that belongs in the services has leaked into the seam.

GraphQL, which Chapter 7 deferred, finds its proper home here. A BFF written by hand has one endpoint per screen, and that works well while the screens are few. When many screens each need their own flexible slice of many services, a federated graph becomes a better BFF strategy. Each service publishes its part of a single typed schema; the client writes a query naming exactly the fields a screen needs; and a client-side cache stores the results normalized, so a concert fetched for one screen is reused by the next. The costs are real, though. Because any client can ask for any combination of fields, authorization must be checked field by field rather than once per endpoint, and the graph itself becomes a shared asset that some team must own, govern and evolve. For Encore today, with two experiences, that price buys nothing a pair of hand-written BFFs doesn’t already give. With 40 screens spread across six teams, the balance would tip the other way.

Native apps change the timing. On the web, a deploy replaces every client within minutes: the next time a fan loads the page, they get the new code. A native app works differently. A release goes through the app store and reaches phones over days, and old versions keep calling our backend for years from the pockets of fans who never update anything. A fan who installed the app for one festival three summers ago may open it next spring still running that same build. So the seam between the fan app and its BFF is not something we can change on a Tuesday afternoon; it is a contract measured in years. The fan BFF therefore treats the app the way Chapter 7 treats outside callers of a public API. It versions its responses so old builds keep receiving the shape they expect. It tracks which app versions still send traffic, so the team knows when an old version has truly gone quiet and its support can be retired. And it keeps a forced-upgrade floor, a minimum version below which the app is told to update before it can continue, for the day an old client becomes a liability, say because it handles payment in a way we can no longer allow. The lesson to carry away is that a native app is a public API consumer that happens to be our own code.

Real-time updates. A seat map is only useful if it is current. During an on-sale, seats vanish by the second, and a map that refreshes only when the fan reloads is a map of seats that were available a moment ago. The browser cannot learn of these changes by itself; the server has to push them. There are two common ways to do that. Server-Sent Events (SSE) give a one-way stream from server to browser over an ordinary HTTP response that simply stays open. WebSockets give a two-way channel, worth its extra complexity when the client also needs to send a steady stream of messages back. Seat availability flows in one direction only, so Encore uses SSE. Because SSE is plain HTTP, it passes through the corporate proxies and load balancers that sometimes block WebSockets, and the browser’s built-in EventSource client reconnects automatically when a stadium’s Wi-Fi drops the connection. Seen from the backend, the browser is simply the last subscriber of Chapter 6’s event backbone: an Inventory event about a seat travels through the backbone and out to every open seat map.

Those open connections come at a price, because each one is state. A server holding a great many open SSE streams cannot be killed and replaced as freely as a stateless request handler can, since every fan connected to it would be cut off at once. So Encore keeps the connections in Section 4.3’s push tier, a layer kept deliberately thin, separate from the rest of the system and tolerant of reconnection: if a push node dies, the fans’ browsers reconnect to another node and pick up the stream again. Everything behind the push tier stays stateless and follows the usual rules, so the one stateful part is small, isolated and easy to reason about.

Optimistic UI. On a crowded on-sale, the round trip to the server may take most of a second, and a seat map that sits frozen after each tap feels broken even when it is working. An optimistic interface solves this by acting first and checking second. When a fan taps a seat, the client marks it as selected at once, as if the server had already agreed. When the server’s answer arrives, the client reconciles: if the hold succeeded, nothing visible changes; if it failed, the client must undo what it showed. That second case needs to be designed, not left to chance. Suppose a fan taps seat 14 in row G, and another fan claimed it a fraction of a second earlier. The fan should see, right away and plainly, that the seat has been taken, with three nearby seats offered in its place, rather than a generic error or a seat that silently un-highlights. An optimistic interface is a promise made before the facts are in, and so it must be designed around the moment that promise is broken.

Recap. Each experience gets its own thin BFF, owned by its frontend team, which calls services in parallel and shapes responses to its screens; a shared BFF drifts into the old shared-integration-layer trap. GraphQL is a BFF strategy for products with many screens and teams, at the cost of field-level authorization and an owned graph. Native clients turn the seam into a contract measured in years, managed with versioned responses, version tracking and an upgrade floor. Real-time UIs subscribe to the event backbone through a thin, stateful push tier, and optimistic UI is eventual consistency with a designed conflict path.

Exercise 8.4. Design the optimistic flow for Encore’s seat tap, covering three parts: the local effect the fan sees immediately, the reconciliation when the server responds, and the conflict path when the seat is already gone. Then describe what the fan sees on screen at 200 ms, at 800 ms and on conflict.

8.5 Security, Accessibility, and AI Interfaces

Securing the browser

Frontend security asks us to turn our backend instincts inside out. On the server we own the machine: we choose what runs on it, who can log in and what it talks to. In the browser we own almost nothing. Our code runs on the fan’s device, beside browser extensions we have never heard of, third-party scripts we invited in for analytics or payments, and, on a bad day, script an attacker has managed to slip into our own page. It is rather like keeping a stall in a crowded market, where any neighbor may reach across the counter. We cannot police the market, so the useful question becomes a narrower one: what do we leave within reach of a hand that isn’t ours? For Encore, three decisions answer that question, and each one covers a gap the others leave.

Token custody. The first decision is where the credentials live. An access token is the key the browser presents to prove who the fan is, and if that key sits anywhere JavaScript can read it, such as localStorage, then any script running on the page can read it too. That is exactly what a cross-site scripting (XSS) attack does: it injects script into our page, reads the token and sends it to the attacker, who can then act as the fan from any machine, for as long as the token stays valid. Encore’s answer is to keep the tokens out of the browser altogether. The BFFs hold the access tokens on the server side and give the browser only a session cookie marked HttpOnly (script cannot read it), Secure (it travels only over HTTPS) and same-site (the browser declines to attach it to most requests started by other sites).

That move closes one door, but it is worth being honest about which one. It stops token theft: there is nothing for injected script to carry away. It does not stop session riding, where the injected script never takes the cookie at all and simply calls the BFF from inside the fan’s own page, and the browser attaches the cookie to those calls just as it would for our own code. The attacker never holds a credential to take away, but for as long as the injected script runs in the fan’s page it can do whatever the fan could, such as add seats to a basket or change an email address. Stopping that requires stopping the injected script from running in the first place, which is why the Content Security Policy below matters as much as the cookie does.

The cookie also reopens an old door. Because browsers attach cookies automatically, a malicious site can try to trick the fan’s browser into sending a request to Encore that carries the fan’s cookie, the attack known as cross-site request forgery (CSRF). The same-site setting blunts most of this, and Encore adds a second guard: every state-changing request, such as placing a hold or confirming an order, must also carry an anti-CSRF token, a value the page received from the BFF and that a foreign site has no way to read. This is the pattern of browser security in miniature. Each defense closes one door and props open another, so we choose our defenses in sets, never one at a time.

Content Security Policy (CSP). The second decision is what code may run at all. CSP is a policy, sent by the server in a response header, that tells the browser which sources of script, style, images and connections the page is allowed to use. In effect it is an execution allowlist: script from an origin the policy doesn’t name simply does not run, even if an attacker managed to inject a tag for it. The difficulty is that a real site loads more than anyone remembers, from the payment provider’s widget to a forgotten analytics snippet, and a strict policy switched on blind would break checkout on the morning of an on-sale. So Encore rolls CSP out in two steps. First it runs the policy in report-only mode, in which the browser enforces nothing but reports every load the policy would have blocked; those reports show what the site actually loads. Once the list has been reviewed and trimmed, Encore switches to enforcement, and from then on only vetted scripts run.

Sanitization. The third decision is how we display content that someone other than us wrote: a fan’s display name, an organizer’s event description, a review. The rule I would ask you to hold onto is that rendering user content is code execution until proven otherwise. If an organizer’s description contains a <script> tag, or an image tag with an onerror handler, and we insert it into the page as HTML, the browser will run it. The defense is to sanitize the content before rendering it, stripping everything outside a small set of harmless tags and attributes, and to do it with an established, maintained sanitization library rather than a homemade regular expression. The reason is not that we are careless but that the space of tricks is enormous: odd encodings, malformed tags the browser repairs in surprising ways, attributes we forgot existed. A homemade regex misses the cases its author never imagined, and an attacker, who has all the time in the world, eventually imagines them. A good library is the accumulated memory of everyone who was caught out before us.

Accessibility as a characteristic. Accessibility belongs beside security in this section for the same reason: both are architectural, because both fail when we try to add them at the end. A system designed without them has decisions baked into its components, layouts and interactions that no late patch can fully undo. The curb cut makes the point well. A ramp at the corner of a sidewalk is cheap to pour when the sidewalk is new and expensive to cut into an old one. And once it exists, it serves people pushing strollers and wheeling suitcases as well as the wheelchair users it was built for. Accessible software behaves the same way: the clear focus order that a keyboard user needs also helps the fan who is checking out on a phone in a noisy queue.

The usual target is the Web Content Accessibility Guidelines (WCAG) 2.2 at level AA, the middle of its three conformance levels and the one most laws and contracts ask for. In the EU it is no longer only good practice but law: the European Accessibility Act has applied to e-commerce, ticketing included, since June 28, 2025, so a ticket site that excludes disabled fans is now out of compliance as well as out of line.

Where accessibility lives in the architecture matters as much as the target. Encore puts semantic structure (real buttons and headings rather than clickable boxes), keyboard paths and color-contrast tokens into the design system, so that a fix made once reaches every team’s screens, and it adds automated accessibility checks to CI so that regressions fail the build. Automation has a limit, though: automated checks catch only a minority of accessibility issues, because a tool can verify that an image has alternative text but not that the text makes sense. So manual testing with a screen reader stays on the release checklist. Encore’s seat map shows why this is architecture rather than polish. It has a keyboard and screen-reader seat picker designed in from the start, not bolted on later. An on-sale is decided in 90 seconds, and a fan who cannot operate the map within that window is not merely inconvenienced but shut out.

The AI-era interface. The browser is also where fans first meet the model-backed features that Chapter 12 builds, and three patterns shape how the frontend presents them.

Streamed responses. A language model produces its answer piece by piece, in small fragments of text called tokens (a different kind of token from the access tokens above), and a full answer can take several seconds to finish. Rather than wait for the whole answer, the page renders each fragment as it arrives. The reason is the one Miller’s work on response time predicts: people judge a system’s speed by how soon it starts responding, not by when it finishes, so text that begins appearing at once feels fast even if the complete answer takes just as long. The machinery is the streaming over HTTP we met in Section 8.4, with one practical twist. The browser’s built-in EventSource, the usual client for Server-Sent Events, can only send a GET request, while a request to a model usually needs a POST to carry the fan’s question and context. So the stream is usually read with a fetch call whose response body the page consumes chunk by chunk.

Generative UI discipline. The second pattern is to treat whatever the model emits as untrusted input, exactly like user content. A model can be manipulated by text hidden in what it reads, an attack called prompt injection, and even without an attacker it can produce markup nobody intended. So model output renders only through an allowlist of vetted components (cards, lists, forms), never as raw HTML. The page also never automatically loads URLs or images that appear in model output. The reason is subtle and worth walking through. Suppose an injected instruction persuades the model to emit an image whose address is the attacker’s server, with the fan’s order details encoded in the query string. The moment the browser fetches that image, the data has left, and no one had to click anything. Refusing to load model-supplied URLs automatically closes that channel.

Human-in-the-loop affordances. The third pattern governs actions. When an AI feature suggests doing something on the fan’s behalf, such as moving them to better seats or rebooking a postponed show, the suggestion arrives as a proposal the fan can see and accept, and once accepted it comes with a visible undo. People trust what they can reverse. I suspect the undo button will do more for trust in these systems than the next improvement in the models behind them, because it turns a model’s mistake from a loss into a minor annoyance.

Recap. In the browser we defend a runtime we do not control, so three measures work together as a perimeter: BFF-held tokens leave nothing for injected script to steal, CSP keeps unvetted script from running, and library sanitization keeps user content from becoming code. Each covers a gap the others leave. Accessibility is architectural for the same reason security is, so it lives in the design system and in CI, poured with the sidewalk rather than cut in later, with manual screen-reader testing for what automation misses. AI features stream their output for perceived speed, render only into vetted components, and propose actions with undo designed in.

Exercise 8.5. Pick one web app you own and answer three questions. Where do its tokens live: in JavaScript-readable storage, or behind an HttpOnly cookie held by a server-side layer? What does its CSP allow, if it has one at all? Which components render user-supplied content, and how is that content sanitized? For each answer, name the incident it permits, for example “an XSS in the review widget could read the access token”.

Kata: The Storefront

This kata asks you to make, for a different company, the decisions this chapter made for Encore: how each page is rendered, how the code and its state are organized, whether the frontend should be split, where the frontend meets the backend, and how the browser is defended. The company is smaller than Encore and sells something softer than concert seats, but it has its own on-sale, and that one day will test every choice you make.

Your brief: “Bloom.” Bloom is a florist marketplace that brings together 2,000 independent flower shops. Most buyers arrive from a search result or an Instagram link, often landing on a page they have never seen before, so two things decide whether they stay: how well the page ranks in search (SEO) and how quickly its first content appears. Shop owners work in a separate portal, open all day on tablets, where they manage orders, arrangements and deliveries. Same-day deliveries are tracked live, and their status changes minute by minute. Bloom has eight engineers: six product engineers and two who are, in the team’s words, “the ones who like frontend.” Mother’s Day is Bloom’s on-sale: traffic rises to 30× normal, and 70% of it comes from first-time visitors on phones.

Deliverables:

  1. Rendering matrix. List every class of page Bloom serves (shop pages, product pages, checkout, the shop-owner portal, delivery tracking and any others you find) and map each one to a row of Figure 8.1. Add a Mother’s Day column that answers, for each page class, one question: does this page stay fast at 30× traffic, and what keeps it fast?
  2. Structure plan. Draw the module boundaries of the codebase. Apply the state taxonomy of Figure 8.2 to Bloom’s data, saying which data is server cache and which is genuinely global client state. Then set two performance budgets and say how continuous integration (CI) enforces each one, so that a change that breaks a budget fails the build.
  3. Composition verdict. Decide how the buyer storefront and the shop-owner portal should be delivered: as one application, as two applications, or as a federation of micro-frontends. Argue the choice from Bloom’s team count, as Section 8.3 argued Encore’s.
  4. Seam design. Specify the backend-for-frontend (BFF) layer: how many BFFs there are, which experience each one serves and which team owns each. Choose the push channel that carries delivery-tracking updates to the browser. Then design the optimistic flow for the action “claim this delivery slot”: what the buyer sees the instant they tap, what happens when the server answers, and what happens when the slot turns out to be gone.
  5. One architecture decision record (ADR). Record Bloom’s decision on token custody (where the access tokens live, and what the browser holds instead) and its Content Security Policy (CSP) posture. Remember that Bloom’s pages embed content that shop owners write themselves, such as shop bios and product descriptions, so the decision must hold even if some of that content turns out to be hostile.

Rubric:

Criterion The question to ask yourself
Per-class rendering Does Mother’s Day traffic land on static or ISR pages served from the CDN, or on servers that render per request?
State classification Is anything held as global client state that is really server cache, borrowed from the backend?
Team-scale sizing Is the composition answer sized to eight engineers, rather than to a company with many frontend teams?
Optimistic conflicts When two buyers claim the same delivery slot, what does the buyer who loses see, and at what moment do they see it?
Perimeter placement Could an XSS payload hidden in a shop’s bio reach a buyer’s session?

Where you now stand

We have now worked through the frontend as an architectural domain of its own. Each page class gets the rendering strategy that suits it. Frontend state is sorted into its three kinds, so that borrowed server data is handled as the cache it is. Each experience has its own thin BFF, owned by the team whose screens it serves. And a deliberate perimeter of token custody, CSP and sanitization surrounds a runtime, the browser, that we do not control. Yet behind every screen we built stands a question this book has deferred twice already: who owns the data those screens display? The answer is different for operational data, which runs the business from moment to moment, and for analytical data, which helps us understand it afterward. It becomes genuinely hard once every team wants to read every other team’s tables. Chapter 9 takes up that question, from distributed ownership of data to the data mesh.

References