- Published on
Scaling the UI: Getting Bytes to the Client Cheaply
- Authors
This is Part 1 of Scaling: The UI / Frontend Track, the third series in a pillar on scaling systems. The first series, Orders of Magnitude, built the mental model; the second, The API / Backend Track, scaled the backend and went asynchronous. This series flips the lens to the client, covering both web and native apps through a shared spine with native callouts where the two differ.
- Part 1: Getting Bytes to the Client Cheaply: the read path, CDNs, caching, payload shape, and pagination.
- Part 2: Real-Time on the Client: polling, SSE, and websockets, and the cost of holding connections.
- Part 3: The UX of Eventual Consistency: representing an asynchronous backend honestly on screen.
- Part 4: Client Resilience and the Cross-Cut: degradation, retries, offline, auth, and observability.
The backend series spent five parts making servers handle more. This series starts from the opposite and complementary idea: the best way to handle load is to not receive it. Every request the client can answer from a cache, an edge, or its own memory is a request your origin never processes and the user never waits for. Scaling the frontend read path is largely the art of serving bytes from as far out as possible. Our worked example stays the food-delivery app, now seen from the customer's device: the restaurant list, the menus, the order status page.
Table of Contents
- The Cheapest Request Is the One You Never Receive
- The CDN and the Edge
- HTTP Caching
- Right-Sizing the Payload
- The Client-Side N+1 Trap
- Pagination Over Everything Large
- Native Callout: On-Device Storage as a Cache Tier
- Conclusion
The Cheapest Request Is the One You Never Receive
The backend series treated every request as work to be scaled. The frontend inverts the frame: a request that reaches your origin has already cost you the most. It consumed a connection, a thread, database load, and the user's patience. The same request served from a CDN edge cost you almost nothing and reached the user in a fraction of the time. The same answer the client already held in memory cost nothing at all.
So the organising principle of this part is a hierarchy of distance. For any piece of data the client needs, ask how far out you can serve it from, and prefer the farthest:
| Served from | Origin cost | User latency | Best for |
|---|---|---|---|
| The client's memory | None | Instant | Data just fetched, this session |
| On-device storage | None | Near-instant | Data from a previous session (native) |
| A CDN edge | Near zero | Very low | Static and slow-changing shared content |
| Your origin | Full | Highest | Dynamic, personalised, fresh writes |
Everything below is techniques for pushing data outward along this hierarchy, so that your origin handles only what genuinely must be served fresh from the center.
The CDN and the Edge
A content delivery network is a fleet of caching servers spread across the world, close to users. Route your traffic through it and requests for cacheable content are served from the nearest edge, never touching your origin, and returning far faster because the bytes travel a short distance instead of across the planet, the physics point from the first series in the client's favor for once.
For a food app, a great deal of what the customer loads is exactly what a CDN is built for. Static assets, the app's JavaScript, CSS, images, logos, are the classic case and should essentially always be served from the edge. But it extends much further than static files. The restaurant list and menus change slowly and are shared across many users, which makes them excellent edge candidates too: cache the menu at the edge for a short window and thousands of customers in a city are served the same menu from a nearby edge while your origin generates it once every so often. That single move can remove a large fraction of read traffic from your backend entirely.
The cost is the recurring one, invalidation, now spread across a global network. When a restaurant changes its menu or goes offline, the edges are briefly stale until their cached copy expires or is purged. You manage this with appropriately short lifetimes for things that change and explicit purges for urgent updates, accepting that truly personalised or real-time content, this customer's current order status, is a poor edge fit and should come from the origin. The judgment is to push everything shared and slow-changing to the edge and keep only the personal and the fresh at the center.
HTTP Caching
Between the origin and the client sits the humblest and most underused scaling tool on the web: HTTP caching, driven by response headers the browser and every cache in between already obey for free.
Caching headers let the server declare how a response may be reused. A long lifetime on immutable assets, versioned bundles that never change, means the browser reuses them without asking again at all. For data that changes, validators let the client ask "has this changed since the version I hold?" and receive a tiny "not modified" answer instead of the whole payload when it has not, saving the bytes and much of the work. And the stale-while-revalidate idea lets the client show cached content instantly while quietly checking for a newer version in the background, so the user gets an immediate response and freshness arrives a moment later without a spinner.
The reason to reach for HTTP caching first is that it requires no new infrastructure, only correct headers, and it is understood by the entire chain from the browser through every proxy and CDN to your origin. Getting cache headers right is one of the highest-return, lowest-cost pieces of scaling work available, and it is routinely left on the table.
Right-Sizing the Payload
Every technique so far reduces how often you serve. Payload shape reduces how much you serve each time, and at scale the multiplier is enormous, a few kilobytes of waste times millions of requests is real bandwidth, real cost, and real slowness on a phone.
The common failure is over-fetching: the order-status screen needs an order's state and the courier's position, but the API returns the full order object, every line item, the complete restaurant record, the customer's history. The screen uses a fraction and the network carried all of it, wasting bandwidth on the wire, memory on the device, and time the user spends waiting. Right-sizing means the payload matches what the screen actually renders. Whether you achieve it by endpoints shaped around real screens or by letting the client request exactly the fields it needs, the principle holds: over-fetching is waste multiplied by your entire traffic, so shaping payloads to the screen is a direct and compounding scaling win.
The Client-Side N+1 Trap
There is a particular client behavior that quietly generates load out of all proportion to what the user is doing, and it is worth calling out on its own: the client-side N+1.
It appears when the client fetches a list and then fires a separate request per item to fill in details, the restaurant list loads, and then the client makes one request per restaurant for its rating, its delivery time, its promotion. A list of fifty restaurants becomes fifty-one requests for one screen. Multiply by every user loading that screen and a single list view is generating a storm of small requests against your backend, precisely the internal fan-out the first series warned about, now manufactured on the client. The fix is to serve the list with the data its rows need in one response, or to batch the follow-up lookups into a single call, so that one screen is one request, or a small handful, rather than dozens. Watching for this pattern is one of the highest-leverage things a frontend engineer can do for backend load.
Pagination Over Everything Large
Any list that can grow must be paginated, because unbounded lists punish everyone as they grow, more bytes over the wire, more memory on the device, more load on the backend, more time before the user sees anything.
Nobody scrolls ten thousand past orders, so nobody should be sent ten thousand past orders. Return a page at a time and fetch more only as the user actually asks, whether through numbered pages or the infinite scroll that loads the next page as they near the bottom. Prefer cursor-based paging, give me the next twenty after this marker, over offset-based paging, give me rows 9,980 to 10,000, because offsets get slower deeper into a list and can skip or repeat items when the underlying data changes between requests, while cursors stay stable and efficient. Pagination keeps the cost of a list bounded and roughly constant regardless of how large the underlying data grows, which is exactly the property you want on both the client and the backend.
Native Callout: On-Device Storage as a Cache Tier
Everything above applies to web and native alike, but native apps add a cache tier the web mostly lacks, and it sits at the top of the distance hierarchy: durable on-device storage.
A native app can persist data in an on-device database or key-value store that survives across sessions and app restarts, not just for the current page load. This turns the device into a genuine cache tier of its own. The app can show the last known restaurant list or the customer's recent orders instantly from local storage the moment it opens, before any network call returns, then refresh in the background, exactly the stale-while-revalidate idea, now spanning sessions rather than a single page. It also underpins the offline behavior that Part 4 covers, since data already on the device is available with no network at all. The tradeoff is the familiar one in a longer-lived form: local data can be stale across sessions, so the app needs a deliberate strategy for when to trust the local copy and when to revalidate. Used well, on-device storage is the client-side extension of this whole part's principle, serving from as far out as possible, pushed all the way out to the device itself.
Conclusion
Scaling the frontend read path is the art of serving bytes from as far out as possible, because the cheapest request is the one your origin never receives. A CDN serves shared, slow-changing content from the edge; HTTP caching reuses responses for free across the whole chain; right-sized payloads and an eye for the client-side N+1 cut how much and how often you serve; pagination keeps large lists bounded; and on native, on-device storage pushes the cache all the way onto the device. Together they remove a large fraction of read load before it ever reaches the backend we spent the last series scaling.
That handles serving data that mostly sits still. But the food app also has data that is alive, the order being prepared, the courier moving across the map, and pushing constantly-changing state to the client is a different problem with its own costs. Part 2 takes on real-time delivery: polling, server-sent events, and websockets, and the surprising scaling cost of simply holding connections open.