0% READ
● A Visual Essay

System Design
Is Not a Crime

Frontend is a distributed system with an untrusted, unobservable replica on hardware you did not choose. A method for designing it well, and a way to tell that from just adding layers.

2026.04.02·17 min·Coding
↓ Scroll

There are two accusations, and they come from opposite directions.

The first comes from outside. It says frontend is not real system design. It says the interesting problems live behind the API gateway, in the sharding strategy, in the consensus protocol, and that what you do is arrange rectangles until a designer stops complaining. This accusation is usually delivered in an interview loop, by someone who has never had to reconcile an optimistic update against a websocket message that arrived out of order.

The second comes from inside. It says you are over-engineering. It arrives in a pull request comment, usually as a question, usually with a smiley face. Why do we need a layer here. Why not just call fetch. This one is more dangerous, because it is sometimes correct.

This article is a defense against both, and it is organized as a method. If you read it start to finish you should end up with a way to design a frontend system that survives contact with a real team, a real network, and a real user on a four year old Android phone in a parking garage.

Part One

What the system actually is

Start here, because most of the dismissal comes from a category error.

A frontend application is a distributed system node that you do not own, cannot observe by default, and cannot restart. It runs on hardware you did not choose, on a network you cannot characterize, under a runtime that the user can pause by switching tabs. It holds mutable replicated state. It has to reconcile that state with an authoritative source over an unreliable channel. It has to remain interactive while doing so.

If you described that to a backend engineer without saying the word browser, they would call it a hard problem.

The specific properties that make it hard:

  • The replica is untrusted and unobservable. Your server knows its own memory. You know what your error boundary caught, if it caught anything, if the user had not already closed the tab.
  • The client is single threaded where it matters. You have one main thread, and it is also the thread that paints. Every synchronous decision you make is a decision to stop the world. Backend systems can throw cores at a problem. You get a scheduler and a plea.
  • Deploys are not atomic. When you ship, existing sessions keep running the old code. You now have two versions of your client talking to one version of your server, indefinitely, because someone left a tab open on Friday. This is a versioning problem that backend teams solve with careful contracts, and frontend teams solve by hoping.
  • The user is a source of concurrent writes. They double click. They hit back. They open the same page in three tabs and edit the same record in two of them.

None of that is arranging rectangles. So: the charge is dismissed. Now the harder part, which is that the second accusation, the over-engineering one, is often true anyway. Design is not the same as adding layers. The rest of this is about how to tell the difference.

Part Two: The Method

Seven steps, in order. The order matters more than any individual step, because the most common failure in frontend architecture is doing step six first. Everyone wants to draw the folder structure. Nobody wants to write down the constraints.

Step 1

Write down the constraints before you draw anything

Not requirements. Constraints. Requirements are what the product wants. Constraints are what reality will do to you regardless.

Four categories, and you should be able to fill each with numbers:

  • Network. What is the p95 latency to your API, from where your users actually are. Not from your laptop. If you do not know, find out before you design anything, because the answer changes the architecture. A 40ms API and a 900ms API are different systems.
  • Device. What is the tenth percentile device in your analytics. Get the actual model. Then look up its single core benchmark score. A mid range Android from three years ago runs JavaScript roughly six times slower than the machine you are reading this on. That ratio is your real performance budget.
  • Data shape. How large is the largest realistic response. How often does it change. Is it per user or shared. Is staleness expensive or free. A stock ticker and a settings page are not the same system even if they render the same components.
  • Team. How many people will touch this, how often, and what do they already know. This is a technical constraint, not a soft one. An architecture that requires everyone to understand your effect system is a bad architecture if half the team joins next quarter.

If you skip this step, every subsequent decision becomes a matter of taste, and arguments about taste do not converge.

Fig. 01 · Four constraints, two systems
Same four questions, different numbers. Switch the system and watch every dial move.
Fig. 02 · Network × device
Same UI code, different system. Toggle the network and device profile and watch the total feel change.
Step 2

Draw the data boundary

Before components, before routing, before anything: decide where each piece of state lives. There are five places, and they are not interchangeable.

Fig. 03 · The five places state can live
Click a zone of the tab, or the server outside it. Each has a different defining property, and that property is the whole argument.

The single most common architectural mistake in frontend is putting server state in a client state container. This is how you get a Redux store with fourteen thousand lines of loading flags. The server state is not yours. It expires. It needs revalidation, deduplication, retry, and garbage collection. Those are cache behaviors, and a cache is what you should use.

ts
type Filters = {
q: string
page: number
status: 'all' | 'open' | 'closed'
}
 
function useFilters(): [Filters, (patch: Partial<Filters>) => void] {
const params = useSearchParams()
const router = useRouter()
const filters: Filters = {
q: params.get('q') ?? '',
page: Number(params.get('page') ?? 1),
status: (params.get('status') as Filters['status']) ?? 'all',
}
const setFilters = (patch: Partial<Filters>) => {
const next = new URLSearchParams(params)
for (const [k, v] of Object.entries({ ...filters, ...patch })) {
next.set(k, String(v))
}
router.replace(`?${next.toString()}`)
}
return [filters, setFilters]
}

That hook is not clever. That is the point. The decision that mattered was made before the code: filters are URL state, so they are not in a store, and the back button works for free.

Step 3

Choose a rendering contract

Server rendered, static, incrementally regenerated, client rendered, streamed. Pick per route, not per application. The framework discourse has convinced people this is an identity, and it is not, it is a per screen decision with three inputs:

  • Does this content need to be in the first HTML payload for SEO or perceived speed
  • Is the data personal, and therefore uncacheable at the edge
  • How expensive is it if the data is a few seconds stale

A marketing page is static. A dashboard is client rendered behind an auth check, because personalizing on the server buys you nothing and costs you cacheability. A product detail page is regenerated on a schedule. A live feed is streamed. One application, four contracts, and nobody has to argue about which one is philosophically correct.

Write the contract down per route. Actually write it down, in a table, in the repo. This is one of the few pieces of frontend documentation that stays true long enough to be worth having.

Fig. 04 · One application, four contracts
Click a route. The two questions on the right are the whole decision, and nobody has to argue about which one is philosophically correct.
Step 4

Design the cache, because that is the actual system

Everything difficult about frontend architecture is a cache coherence problem wearing a costume.

Four questions per resource:

  • How long is it fresh. How long can you serve it without asking again.
  • How long do you keep it after it goes stale. Stale data displayed instantly while you revalidate is usually better than a spinner. Usually.
  • What invalidates it. A mutation, a websocket event, a route change, a timer, a window refocus.
  • What is the key. This is where the bugs are. If your key does not include every input that affects the response, including auth scope and locale, you will eventually serve one user another user's data, and you will find out about it from a screenshot on social media.

Answer these once, in a table, and the table becomes your architecture. Then encode it, rather than reimplementing it per feature:

ts
export const cachePolicy = {
session: { staleTime: Infinity, gcTime: Infinity },
reference:{ staleTime: 60 * 60 * 1000, gcTime: 24 * 60 * 60 * 1000 },
listing: { staleTime: 30 * 1000, gcTime: 5 * 60 * 1000 },
live: { staleTime: 0, gcTime: 60 * 1000, refetchInterval: 5000 },
} as const
 
export function useOrders(filters: Filters) {
return useQuery({
queryKey: ['orders', filters],
queryFn: () => fetchOrders(filters),
...cachePolicy.listing,
})
}

The value is not in the code. It is that “how stale can this be” becomes a named choice a reviewer can challenge, rather than a default nobody noticed.

Fig. 05 · Fresh → stale → collected
Drag staleTime and gcTime. Watch what a component sees if it mounts at that instant.
Step 5

Design the failure states before the success state

Every asynchronous surface has at least five states: empty, loading, success, error, and stale. Most codebases implement two of them and then patch in the others under deadline, which is why so many applications have a spinner that lasts forever when the network drops.

Decide, per surface:

  • What shows on first load with no data
  • What shows on refetch with existing data. This is the one people miss. Replacing rendered content with a spinner because the user changed a filter is a downgrade
  • What shows when it fails, and whether the user can retry without losing their place
  • What shows when there is genuinely nothing, and whether that is different from failure
  • What happens when it is stale but usable

The retry policy is a design decision, not a library default. Retrying a failed GET three times with backoff is reasonable. Retrying a failed POST is how you charge someone twice. Idempotency is not only a backend concern. If your mutation can be replayed, it needs a key, and the client is what generates it.

Fig. 06 · Five states, one surface
Click through the wheel. Success is one fifth of the job. The other four are where the deadline patches usually happen.
Step 6

Draw the module boundaries, now

Now, not earlier. Because now you know which parts of the system change together, and that is the only real criterion for a boundary.

The useful test is the reversal cost. For each decision, ask what it costs to undo in six months. Renaming a component: minutes. Swapping a CSS approach: painful but mechanical. Changing where server state lives: a rewrite. Changing your data fetching boundary: a rewrite. Changing your rendering contract on a route that other teams now depend on: a negotiation.

Design the expensive to reverse decisions. Do not design the cheap ones.

This single rule is the answer to the over-engineering accusation, and it cuts both ways. It says the abstract factory for your button variants is not architecture, it is decoration, delete it. It also says that the thin layer between your components and your transport is not over-engineering, it is the difference between changing your API client in one file or in four hundred.

When someone says “why not just call fetch,” the honest answer is a question about reversal cost. If the answer is “we would touch three files,” they are right and you should just call fetch. If the answer is “every component in the application,” you are not over-engineering, you are declining to distribute a decision you will need to change.

Fig. 07 · Reversal cost, by decision
Click a decision. The gauge is how expensive it is to undo, and that cost is the entire argument for designing it or leaving it alone.
Step 7

Define what you can see

You cannot debug what you cannot observe, and the browser observes nothing by default. Before you ship, decide:

  • Which user actions emit events, and with what schema. Not which analytics vendor. The schema. Vendors change, event names live forever
  • What gets attached to every error: route, release version, user segment, last few navigation steps
  • Which performance metrics you actually watch, and what the threshold is at which someone is woken up
  • How you correlate a client error with a server trace. If you are not propagating a request identifier from the client, you are asking your backend team to guess

The versioning problem from Part One shows up here. Tag every event and error with the client release. Otherwise, when errors spike, you cannot tell whether you broke something or whether four thousand people are running a build from March.

Part Three: The Quirky Corners

Everything above is the linear spine. These are the parts that make frontend genuinely strange, and they are the parts that never appear in the standard system design curriculum.

  • The URL is a database with one table and terrible types. It is also the only piece of state your users can copy, paste, bookmark, and send to a colleague. Treat it with the seriousness of a schema. Every filter you put in a store instead of the URL is a feature you have silently removed.
  • The back button is a distributed systems problem. It is a request to restore a previous state of a system that has since changed, including scroll position, focus, in flight requests, and cache contents. Browsers give you a rough approximation. The gap between that approximation and what users expect is where a surprising amount of engineering time goes.
  • Hydration is telling the same lie twice and hoping the stories match. You render markup on the server, ship it, then render it again on the client and compare. When they disagree, which happens the moment anyone calls Date.now() or reads localStorage during render, you get a mismatch. The mental model that saves you: server render is a pure function of the request, client render is a pure function of the request plus the browser. Anything in the second set has to happen after mount, not during render.
  • Your bundle is a latency budget denominated in bytes. Every dependency is a decision to spend some of your user's time. And it is not just transfer, it is parse and execute, on that tenth percentile device, on the single thread that also paints. A 200KB library on a mid range phone can cost more than the network request it saves you.
  • State managers are caches with worse defaults. Most global state libraries were designed for client state and then used for server state, which is why so many of them grew async middleware they never wanted. If your store contains something that came from the network, you have a cache, and you should ask it the four cache questions from step four.
  • Optimistic updates are structured lying, and lying requires a plan. Showing the result before the server confirms it is good design. It is also a promise, and you need a rollback story, an error surface that does not silently discard the user's work, and a rule for what happens when two optimistic updates to the same record are in flight. The last one is where the bugs live.
  • Every click is a potential race. The user types, you fire a request per keystroke, responses arrive out of order, and the list now shows results for “rea” while the input says “react.” Every async read needs either cancellation or a staleness check on arrival. Every async write needs a concurrency policy: latest wins, queue, or reject.
Fig. 08 · A search box, racing itself
Type “react” in naive mode and watch a late response overwrite a newer one. Switch to fixed and it can't happen.
  • Time zones will find you. Not eventually. Specifically at 11pm on the last day of the month, when a user in a negative UTC offset reports that their monthly summary is wrong. Store instants, render in the viewer's zone, and never construct a date from a string without a zone.
  • Third party scripts are unaudited code with production access. The tag manager, the chat widget, the session recorder. They run on your main thread, in your origin, with your user's data, and they update without telling you. Every one of them is a dependency you did not review, and at least one of them is why your interaction latency degraded last quarter.
  • Accessibility is a correctness constraint, not a polish task. A focus trap that does not trap is a broken modal. A dynamic region that does not announce is a state change that a subset of your users cannot perceive. This belongs with your other invariants, not on a checklist at the end.
Part Four: The Worked Example

To make the method concrete, a support ticket dashboard. Live queue, filters, detail panel, inline status changes, ten thousand active users, mostly on desktop, some on tablets in warehouses over hostile wifi.

Fig. 09 · Seven decisions, one dashboard
Click a category. It highlights the part of the mockup that decision governs, and the reasoning behind it.

Constraints. API p95 is 350ms. Tenth percentile device is a low end tablet. Queue changes constantly, ticket bodies rarely. Five engineers, two of whom joined this month.

Data boundary. Ticket list and detail are server state. Filters, selected ticket, sort are URL state, because agents share links to specific views constantly. Draft reply text is client state, and it must survive navigation, so it is keyed by ticket in memory. Auth and permissions are session state. Scroll position in the queue is DOM state, restored on back.

Rendering contract. Everything behind auth, so client rendered with a server rendered shell. No SEO consideration. No benefit to server personalization.

Cache policy. Queue is live: zero stale time, poll every five seconds, refetch on focus. Detail is listing: thirty seconds stale, cached five minutes, because agents flip between tickets constantly and a spinner every time is unacceptable. Permissions are session: fetched once. Invalidation: a status mutation invalidates the queue and patches the detail optimistically.

Failure states. The queue keeps showing the last known list when a poll fails, with a subtle stale marker, because an agent staring at an empty screen is worse than one looking at data from twenty seconds ago. Detail load failure gets an inline retry that does not close the panel. Status mutation failure rolls back the optimistic change and surfaces a toast with a retry that carries the original idempotency key.

Boundaries. One transport module. One cache policy module. One feature folder per surface. No shared component library beyond primitives, because with five engineers the coordination cost exceeds the reuse benefit. That last decision is cheap to reverse, which is exactly why it does not need to be made now.

Observability. Every mutation emits an event with ticket id, agent id, and release. Errors carry the last three routes. Interaction latency on the status control is watched, because it is the highest frequency action in the product.

Notice that almost none of that is about React. The framework shows up at the end, as an implementation detail, which is the correct place for it.

The verdict

The dismissal from outside is wrong on the facts. What runs in a browser is a partially connected, untrusted, unobservable, concurrently mutated replica of your data, executing on a single thread you share with the compositor, in a runtime version you cannot pin, on hardware you did not choose. Designing that well is systems work by any definition that is not simply gatekeeping.

The accusation from inside deserves more respect, because it is aimed at something real. A lot of what gets called frontend architecture is decoration: abstractions with no reversal cost, indirection that exists so that a diagram looks symmetrical, layers that add a hop and remove nothing. That is not design, and defending it makes the whole discipline easier to dismiss.

The line between them is reversal cost. Design the decisions that are expensive to undo: where state lives, what your cache promises, what your failure modes are, what you can see in production. Leave everything else concrete and boring and easy to delete.

Do that, and when someone asks why you did not just call fetch, you will have an answer that is not about taste.

Case closed.

END · 17 MINReply by email
◆ Newsletter

New essays. New tracks. One email a month, max.

Reply to anything I send and it goes straight to me.

NEXT →

The Shape of a Sound

Sine, square, triangle, sawtooth, noise: why each waveform sounds the way it does, and when to reach for which.