Networking and Data Fetching
REST, GraphQL, and tRPC from the consumer side, how request waterfalls happen, and the three fetching patterns that decide what your loading states look like.
Advertisement
Why It Matters
Frontend performance work usually starts with bundle size, because that is what tooling measures loudly. But for most real applications the dominant cost is not the JavaScript - it is the network round trips, and specifically the ones that happen in sequence when they could have happened at the same time.
A page with four 400ms requests takes 400ms if they overlap and 1.6 seconds if they do not. No amount of code splitting recovers that. The architecture of how you fetch matters more than the size of what you shipped.
REST, GraphQL, and tRPC from the Consumer Side
REST
Resources at URLs, verbs as semantics. The frontend's experience is defined by two chronic mismatches:
Over-fetching. GET /users/123 returns everything a user is. Your avatar component needs a name and an image URL. You pay for the rest, on every request, on whatever connection the user has.
Under-fetching. A single screen needs a user, their orders, and each order's shipping status. That is not one endpoint - it is three or more, and often nested, which is where waterfalls come from.
What REST gets right is worth stating clearly, because it is usually undersold: URLs are cacheable. HTTP caching, CDN caching, and conditional requests via ETags all work without you designing anything. That is a substantial and often decisive advantage.
GraphQL
One endpoint, and the client declares the exact shape it wants. Both mismatches disappear at the wire level - you ask for three fields, you get three fields; you ask for a user with their orders and each order's status, and it arrives in one round trip.
The costs land elsewhere:
- HTTP caching largely stops working. Requests are typically POSTs to a single URL, so the CDN and the browser cache have nothing to key on. You replace a free layer with a client-side normalised cache that you now own.
- Expensive queries move to the server. A client can request a deeply nested query that is cheap to write and brutal to resolve. Query depth limits and cost analysis become a requirement, not a nicety.
- The N+1 problem. Resolving a list of users and each of their orders naively issues one query per user. DataLoader-style batching is not optional at any real scale.
- Client weight. A capable GraphQL client with normalised caching is a meaningful bundle cost.
tRPC
No schema language, no code generation. The server defines procedures, and the client imports the type of the router directly, so calling a procedure is type-checked end to end by the TypeScript compiler.
The developer experience is excellent when the preconditions hold. Rename a field on the server and the client fails to compile immediately - not at runtime, not in QA. There is no schema artifact to keep in sync, because there is no artifact.
The preconditions are strict: the same team owns both ends, both are TypeScript, and the API is internal. tRPC's type safety comes from sharing source types at compile time, which a mobile client, a partner integration, or a public API fundamentally cannot do.
Request Waterfalls
A waterfall is a sequence of requests where each cannot start until the previous finished. It is the single most common cause of a page that feels slow despite a small bundle.
Two distinct causes, with different fixes.
Rendering-induced waterfalls. With component-level fetch-on-render, a component mounts and then starts its request. Its child cannot mount until the parent has data, so the child's request cannot start either. Three levels of nesting produce three sequential round trips - and there was never any actual data dependency between them.
Genuine data dependencies. You need a user's ID before you can request their orders. The second request truly cannot start first.
Diagram100%sequenceDiagram participant B as Browser participant API as Server Note over B,API: WATERFALL - 1600ms total B->>API: GET /user API-->>B: user (400ms) B->>API: GET /orders API-->>B: orders (400ms) B->>API: GET /recommendations API-->>B: recs (400ms) B->>API: GET /notifications API-->>B: notifications (400ms) Note over B,API: PARALLEL - 400ms total par all four at once B->>API: GET /user and B->>API: GET /orders and B->>API: GET /recommendations and B->>API: GET /notifications end API-->>B: all four resolve (400ms)visualized by
The architectural fixes, in order of preference:
Hoist and parallelise. If the requests are independent, fire them together. A route-level loader that knows all of a page's data requirements upfront is structurally incapable of producing a rendering-induced waterfall, because nothing waits for a component to mount.
Compose on the server. For genuine dependencies, the client cannot parallelise - but the chain does not have to run at browser-to-server latency. A BFF or server component resolves it inside the data centre, where each hop is single-digit milliseconds. Four dependent hops at 5ms is 20ms of server time and one 400ms round trip, instead of 1.6 seconds.
Batch at the data layer. A DataLoader collapses many individual lookups within a tick into one batched request, which is what makes list-and-detail patterns viable on the resolver side.
Preload on intent. Start fetching on link hover or focus, before the click. Users take a few hundred milliseconds between intent and action - that is free latency to spend.
Three Fetching Patterns
The pattern you choose determines what your loading states look like, which is mostly what users perceive as speed.
Fetch-then-render
Fetch everything, render nothing until it all arrives.
One loading state, no layout shift, no pop-in. But time to first content is bounded by your slowest request, so one slow recommendation service holds the entire page hostage. This is the pattern behind "the page was blank for three seconds and then appeared all at once."
Fetch-on-render
Render immediately; each component starts its own request in an effect as it mounts.
Simple, colocated, and the code reads well - each component owns its data. It is also the pattern that manufactures waterfalls, and it produces the cascading-spinner UX where regions pop in one after another and the layout jumps repeatedly.
Render-as-you-fetch
Start the request as early as you know it will be needed - at route match, on hover - and render whatever is ready while the rest streams in.
Diagram100%flowchart TB subgraph FTR["FETCH-THEN-RENDER"] A1["Fetch all"] --> A2["Wait for slowest"] --> A3["Render everything"] A4["One spinner, no shift, slowest request gates the page"] end subgraph FOR["FETCH-ON-RENDER"] B1["Render"] --> B2["Mount - fetch starts"] --> B3["Data arrives"] --> B4["Child mounts - fetch starts"] --> B5["Waterfall"] B6["Cascading spinners, layout shift, sequential latency"] end subgraph RAYF["RENDER-AS-YOU-FETCH"] C1["Route match - fetch starts"] --> C2["Render shell immediately"] C1 --> C3["Data streams in"] C2 --> C4["Fill regions as they resolve"] C3 --> C4 C5["Skeleton fills in, no waterfall, network and CPU overlap"] end style FTR fill:#3f2d1e,stroke:#f59e0b style FOR fill:#3f1e1e,stroke:#ef4444 style RAYF fill:#1e3a5f,stroke:#3b82f6visualized by
Render-as-you-fetch is the target architecture, and it requires something the other two do not: data requirements must be knowable before the component renders. That is why it pairs with route-level loaders, server components, and streaming - all of which move the "what does this page need?" question earlier than mount time.
Cache Invalidation Strategies
Three mechanisms, matched to three different reasons data goes stale. Most applications need all three.
Time-based. An entry is fresh for N seconds, then stale. Simple, requires no coordination, and works for data that drifts on its own with no client-visible trigger - exchange rates, aggregate metrics, trending lists. The window is a correctness decision: set it to how wrong you can afford to be, not to a round number.
Event-based. Something happened, so specific entries are now wrong. After a successful mutation you invalidate exactly the keys that mutation affected. This is precise and immediate, and it is the right default for anything the user themselves changed. Its weakness is that it only catches changes you initiated - another user's write is invisible to it, which is what focus-refetch and websockets exist to cover.
Tag-based. Cache entries carry tags; you invalidate by tag rather than by key. This is what you need when one write touches many cached entries. Updating a product's price should invalidate the product detail page, every listing containing it, the search results, and the cart - and enumerating those keys by hand is both tedious and guaranteed to miss one. Tag every entry that includes product 123 with product:123, invalidate the tag, and they all clear together.
The rule of thumb: reach for tags when the set of affected entries is not knowable at mutation time. That is more often than teams expect, and hand-enumerating keys is where stale-data bugs actually come from.
Tradeoffs
| Option | Pros | Cons | When to Use |
|---|---|---|---|
| REST | Free HTTP and CDN caching, universally understood, easy to debug | Over-fetching, under-fetching, many endpoints per screen | Public APIs, cacheable resources, consumers you do not control |
| GraphQL | Exact payload shape, one round trip for nested data, self-documenting schema | HTTP caching largely lost, expensive queries need cost limits, N+1 on resolvers, heavier client | Many clients with different data needs, deeply relational data |
| tRPC | End-to-end types with no codegen, server changes break the client at compile time | TypeScript both ends, same team, unsuitable for public or third-party consumers | Internal full-stack TypeScript products |
| Fetch-then-render | One loading state, no layout shift | Slowest request gates the whole page | Small pages where all data resolves at similar speed |
| Fetch-on-render | Simple, colocated, each component owns its data | Manufactures waterfalls, cascading spinners, layout shift | Prototypes, or genuinely independent leaf widgets |
| Render-as-you-fetch | No waterfalls, network and CPU overlap, coherent skeleton | Needs route loaders or server components, requirements must be known before render | Any production page where load time matters |
Where This Applies
The cache this article invalidates is the one introduced in State Management Architecture - invalidation strategy and server-state categorisation are two halves of the same decision. Streaming SSR, which is what makes render-as-you-fetch possible on the server side, is covered in Rendering Architecture, and the BFF that collapses a client-side waterfall into server-side composition is in Application Architecture at Scale. The HTTP and CDN caching layers that REST gets for free are examined in Performance Engineering.
In the applied practice problems, this shows up first in Autocomplete / Typeahead, where request cancellation and deduplication decide whether typing feels instant, and in Infinite Scroll, where cache key design determines whether pagination survives a refetch. Transport selection for server-initiated data is the subject of Real-Time Feed.
Advertisement
A page takes four seconds to load. Network shows four requests, each about 400ms, running one after another. What is wrong and how do you fix it?
That is a request waterfall - each request cannot start until the previous one finished, so latency adds up instead of overlapping. The usual cause is either component-level fetch-on-render, where a child only mounts and requests after its parent's data arrives, or a genuine data dependency where one response contains the ID needed for the next. For the first, hoist the requests so they fire together in parallel. For the second, the client cannot parallelise it, so move the composition server-side - a BFF or a server component resolves the chain inside the data centre where each hop is single-digit milliseconds instead of 400.
Why does GraphQL not automatically solve over-fetching in practice?
Because it solves over-fetching of fields and introduces other problems in its place. Clients can still request enormous nested queries that are expensive for the server to resolve, and the flexibility that makes the client efficient makes server-side caching much harder, since every unique query string is effectively a unique cache key rather than a cacheable URL. You also inherit the N+1 problem on the resolver side, which needs batching to fix. GraphQL moves the over-fetching problem from the wire to the resolver layer - it does not delete it.
What is the difference between fetch-on-render and render-as-you-fetch, and why does it matter?
Fetch-on-render means the component mounts, then starts its request in an effect, so rendering gates fetching and every nesting level adds a round trip. Render-as-you-fetch starts the request as early as you know it will be needed - at route match or on link hover - and renders whatever is ready while the rest streams in. The difference matters because in the first pattern the network is idle while the browser renders and the CPU is idle while the network works, whereas the second overlaps them. It also changes the loading UX from a cascade of nested spinners to one coherent skeleton that fills in.
How do you decide between time-based, event-based, and tag-based cache invalidation?
Match the mechanism to what actually changes the data. Time-based fits data that drifts on its own with no client-visible trigger, like an exchange rate or a metrics dashboard, and the window should be set by how wrong you can afford to be. Event-based fits data invalidated by a known action - after a successful mutation you invalidate exactly the keys that mutation affected. Tag-based is what you need when one write touches many cached entries, where you tag entries by entity and invalidate by tag so a single product update clears the listing, the detail page, and the search results together. Most real applications use all three.
When would you choose tRPC over REST or GraphQL?
When the same team owns both ends and both are TypeScript. tRPC gives you end-to-end type inference with no schema file, no code generation step, and no build artifact - rename a field on the server and the client fails to compile immediately. That is a genuinely better developer experience than either alternative. It stops being the right answer the moment you have a consumer you do not control, a non-TypeScript client, or a public API, because the type safety comes from sharing types at compile time, which those consumers cannot do.