ESC
Type to search guides, tutorials, and reference documentation.
← Back to all categories
🎨

Frontend Architecture

Rendering strategies, state ownership, data fetching, component boundaries, performance, and accessibility — the architectural decisions that determine whether a frontend stays maintainable as it grows.

Frontend architecture is the set of decisions about where state lives, when rendering happens, and what crosses the network. Framework choice gets the attention, but frameworks are largely interchangeable in their capabilities; the decisions above are what determine whether an application stays workable at twenty engineers and three years, or becomes something nobody can change safely.

What frontend architecture decides

A frontend runs in an environment you do not control: an unknown device, an unpredictable network, a browser version you did not choose, with a user who may have assistive technology, reduced motion enabled, or a slow connection. Server engineering optimises within a known envelope. Frontend engineering has to degrade gracefully across an unknown one.

That reframes the core questions. Not "which framework", but: what does the user see before JavaScript finishes? Who owns each piece of state? How many round trips before the page is useful? What happens on a flaky connection? What happens with a keyboard and no mouse?

Rendering strategies

StrategyHow it worksBest forCosts
Static generationHTML built at deploy time, served from a CDNContent that changes infrequently and is the same for everyoneRebuild to change; poor fit for per-user content
Server renderingHTML generated per requestPersonalised or frequently-changing content that must be fast to first paint and indexableServer cost per request; needs caching discipline
Client renderingShell loads, JavaScript fetches data and builds the DOMHighly interactive application-like surfaces behind a loginBlank screen until JS runs; weaker for content discovery
Hybrid / streamingServer-rendered shell, progressively enhanced and hydrated in piecesMost real applications, which have both content and interactionThe most complexity: two execution environments, hydration correctness

The decision is usually per-route, not per-application. A marketing page and a data-heavy dashboard in the same product have opposite requirements, and treating them identically means one of them is badly served.

Hydration is the seam where hybrid approaches break. The server produces HTML, the client re-runs the component logic and attaches behaviour, and if the two disagree — because of a timestamp, a random value, a locale difference, or something read from the browser — you get a mismatch that either throws away the server work or produces a UI that visibly changes after load. Anything non-deterministic must be rendered after hydration, not during it.

The four kinds of state

Most frontend complexity comes from treating fundamentally different things as one undifferentiated blob of "state". They have different owners and different correctness rules:

  • Server state. Data that lives in a database and is merely cached in the client. It can become stale without you touching it, and it needs fetching, revalidation, and error handling — not a setter.
  • URL state. Which page, which filters, which selected item, which tab. If it belongs in the URL and is not there, users cannot share, bookmark, or use the back button, and every deep link is broken.
  • Client state. Genuinely local UI concerns: is this menu open, which step of a wizard, what has been typed but not submitted.
  • Form state. Values plus validation plus submission status plus dirtiness — enough distinct concerns that it usually deserves dedicated handling.

The single most common architectural error is copying server state into a global client store and then manually keeping the copy in sync. Every mutation now has two places it must be reflected, and they will drift. The durable rule: keep exactly one source of truth per fact, and derive everything else. If you can compute it, do not store it.

Data fetching and cache invalidation

Two structural problems dominate. The first is the request waterfall: a component renders, fetches, renders a child, which fetches, and so on. Each level adds a full round trip, so the page's time-to-useful is the depth of the tree times the network latency — which is why a waterfall that is imperceptible on a fast connection is disastrous on a slow one. The fix is to hoist fetching so independent requests start in parallel, or to move composition to the server where the round trips are cheap.

The second is invalidation after mutation. When a write succeeds, some set of cached reads is now wrong. Deciding that set explicitly — rather than refetching everything, or nothing — is the core of any data-fetching layer.

Optimistic updates make an interface feel immediate by applying the expected result before the server confirms. They are worth it for high-frequency, low-risk, usually-successful actions. They are a poor trade when failure is likely or the rollback is confusing, and they always require you to design the rollback path — an optimistic update with no error path is a bug that shows users a change that did not happen.

Component boundaries and design systems

A component boundary should follow a reuse or ownership boundary, not a visual one. Splitting purely because a file grew long produces components with a dozen props that only ever have one caller, which is harder to read than the long file was.

The most durable separation is between components that know about data and components that only render what they are given. The latter are trivially testable, reusable, and previewable; the former can be kept few and shallow.

For design systems, prefer composition over configuration. A component that accumulates boolean props for every variant becomes an unreadable conditional tangle; one that exposes composable parts lets callers build variants you did not anticipate. And unless the system is genuinely closed, let callers escape — a design system with no override mechanism gets forked, and a forked design system is worse than none.

Performance

Frontend performance is mostly about three budgets: the network, the main thread, and layout stability.

On the network, what matters is the critical path — the resources that must arrive before the page is useful. Render-blocking stylesheets and scripts in the document head, synchronously-loaded fonts, and uncompressed images all extend it. Code splitting helps when the split boundaries match how users actually navigate; splitting on arbitrary lines just converts one download into several round trips.

On the main thread, the browser cannot paint or respond to input while JavaScript is executing. Large parsing and hydration costs, expensive re-renders, and synchronous work in event handlers all show up as unresponsiveness rather than as a slow load, which is why a page can score well on load metrics and still feel broken. The common re-render pathology: a value recreated on every render is passed as a prop, so memoisation never matches and an entire subtree re-renders on every keystroke.

On layout stability, content that arrives late and pushes existing content down is one of the most disliked behaviours on the web, and it is almost always preventable by reserving space — explicit dimensions on images and media, and placeholders sized like the content they will be replaced by.

Measure on representative hardware and networks. The machine you develop on is not the machine most users have, and a build that feels instant locally can be unusable on a mid-range phone on a congested mobile network.

Accessibility

Accessibility is an architectural property, not a late pass. Retrofitting it into a component tree built from generic containers with click handlers is far more expensive than getting the semantics right initially.

The foundations are consistent: use the element that means what you are doing — a real button, a real link, a real form control — because native elements bring focus behaviour, keyboard handling, and assistive-technology semantics for free. Maintain a visible focus indicator and a sensible tab order. Manage focus on navigation and on opening or closing overlays, since a modal that does not receive focus, trap it, and return it is unusable by keyboard. Do not convey meaning through colour alone. Announce asynchronous changes through live regions, or screen-reader users will never learn that the save succeeded. Respect the user's reduced-motion preference.

ARIA is a repair tool, not a foundation. Incorrect ARIA is worse than none, because it overrides accurate native semantics with an inaccurate description.

Build and delivery

Ship content-hashed filenames with long-lived immutable cache headers, and keep the HTML document itself short-lived or revalidated — that combination gives you both instant repeat visits and the ability to deploy at any time. Serve compressed text assets and appropriately sized, modern-format images.

Watch dependency weight deliberately. A utility library pulled in for one function, a date library with every locale bundled, or an icon set imported wholesale can dwarf your own application code. Size budgets enforced in the build are the only mechanism that reliably stops gradual bloat, because no single pull request is ever the problem.

Testing

Test behaviour through the interface the user has: query by role and label, interact the way a person would, and assert on what is rendered. Tests written against implementation details — internal state, component internals, class names — fail on every refactor and pass through real regressions, which is the exact opposite of what you want.

Keep the expensive end-to-end layer small and focused on genuine cross-cutting journeys; cover the combinatorial cases at the component level where tests are fast and failures point directly at the cause.

Common failure modes

  • Server state in a global store. Two copies of the truth, kept in sync by hand.
  • State that should be in the URL. Breaks sharing, bookmarking, and the back button.
  • Fetch waterfalls. Sequential round trips that should have been parallel.
  • Hydration mismatch. Non-deterministic values rendered on both server and client.
  • No loading or error states. Designed for the happy path only; the real network is neither instant nor reliable.
  • Div-with-onclick. Not focusable, not keyboard operable, not announced.
  • Unreserved space. Late-arriving content shifting the page under the user's finger.
  • Prop-drilling solved with a single global context. Every consumer re-renders on every unrelated change.

Choosing an approach

Static generation for content that is the same for everyone and changes on a deploy cadence. Server rendering when content is personalised or fresh and the first paint matters — public, discoverable, conversion-sensitive surfaces. Client rendering for long-lived, interaction-dense application surfaces behind authentication, where the initial load is amortised over a long session. Hybrid when the product has both, which most do.

The case against a heavy client application is worth stating plainly: if the interface is mostly forms and content, server-rendered pages with modest enhancement will be faster to build, faster to load, more robust on poor connections, and accessible almost by default. Reach for a large client-side architecture when the interactivity genuinely requires it, not by default.