Skip to article
EngineeringPerformance 11 min read

The one-request rule: screens that load once

We rebuilt the analytics dashboard around server-rendered shells and request coalescing. p95 fell from 184 ms to 92 ms, and the client got 1,240 lines lighter.

LV

Lina Voss

Platform engineering

Fig. 01 — request waterfall
critical pathdeferred

Before — chained hooks

184ms p95

14 requests
session
team
stats
feed
avatars
flags
050100150200 ms

After — parallel reads

92ms p95

6 requests
session
stats
feed
team
flags
avatars
050100150200 ms
measured on the analytics route · throttled 4G, cold cachen = 1,280 sessions
Four chained reads become three parallel reads inside one document request. The critical path shrinks before any server optimization.
p95 latency
184 → 92 ms
Requests / view
14 → 6
LCP
1.9 → 1.1 s
Client state
−1,240 lines

The dashboard felt instant in demos and sluggish in the office. The API was not the problem. The shape of the requests was: session, then team, then stats, then feed. Four round trips, each one waiting for the previous to resolve before it could even start.

On a warm connection that reads as 184 ms at p95. On a train, it reads as a spinner. The browser was doing exactly what we asked: coordinate.

The one-request rule

A screen should need one document request to become readable. Everything else is an enhancement. If a route needs a second blocking round trip for its primary content, the latency budget now belongs to the network.

The waterfall tax#

It is tempting to blame the slowest endpoint. In our trace, no single endpoint took more than 48 ms. The damage came from addition: four reads of roughly 40 ms produced a 184 ms path because none of them could overlap.

We called it the waterfall tax. It compounds quietly, because every new piece of context — a team switcher, a feature flag, an avatar — arrives as another hook that depends on the value before it. The fix is architectural, not a faster cache.

What we measured#

We instrumented four surfaces for two weeks before touching the architecture. The table shows median and p95 time-to-readable on a throttled 4G profile with a cold cache. Requests count every blocking read before the primary content is readable.

Time-to-readable before and after the rewrite, by surface
SurfaceRequestsp50p95Δ p95
Overview14 → 6112 → 58184 → 92−50%
Analytics18 → 7141 → 69226 → 108−52%
Billing9 → 874 → 66118 → 96−19%
Settings7 → 561 → 4496 → 71−26%

All values in milliseconds. Lower is better. Measured July 2026.

The billing page barely moved. That was useful: it told us the win came from removing sequential dependencies, not from a faster server. Billing already fetched its data in parallel.

Collapse the requests#

Two changes did most of the work. First, the shell became a server component that reads the session cookie directly. Second, independent reads moved into a single Promise.all, so the slowest read sets the budget instead of the sum of every read.

"use client";export function Dashboard() {  const session = useSession();  const team = useTeam(session?.teamId);  const stats = useStats(team?.id);  const feed = useFeed(team?.id);  // four round trips before first paint  return <Frame session={session} team={team} stats={stats} feed={feed} />;}

The after version still has four dependencies; it just does not serialize them. The server can read the session and start the other reads at the same time, because scoping happens inside the data layer rather than in the browser.

Watch the boundary

Do not move a hook to the server by renaming it. The data layer must own its authorization, or you have traded a waterfall for a leak.

What actually changed

  1. 01The session is read from cookies on the server.
  2. 02Query scoping moved into the repository layer.
  3. 03Independent reads are batched with Promise.all.
  4. 04The feed streams, so the shell paints before it resolves.

Ship the shell first#

A fast dashboard is mostly a promise about what can wait. We split the page into three priorities, and gave each one a budget instead of a wish.

The frame0 ms
Navigation, title and layout skeleton are static. They stream on the first flush, before any data resolves.
The numbers~40 ms
Aggregate counts are scoped server-side and cached at the edge for thirty seconds. No personal data, no waterfall.
The feed~90 ms
The only personalized region arrives behind Suspense. It has the only skeleton, and it never blocks the shell.

The feed is the only part that may arrive late, and it is also the only part with a skeleton. The shell never waits for it. That single decision removed the layout shift that used to make the whole page feel broken on a slow connection.

“The fastest request is the one the browser never has to coordinate. Every dependency you remove is latency you stop paying for on every visit.”
Ines Kovac, staff engineer, platform

The trade-offs#

None of this is free. Server components make the boundary between data and presentation more explicit, and that boundary takes discipline. We also lost some client-side cache tricks: optimistic updates now need an explicit mutation path.

The team accepted the trade because the failure mode changed from a blank screen to a stale row. A stale row can be fixed on the next poll. A spinner at the top of the page cannot be fixed by anything except the network.

What improved

  • p95 time-to-readable cut in half
  • 1,240 fewer lines of client state
  • One document request per route
  • Two fewer full-page spinners

What got harder

  • A stricter server/client boundary
  • Explicit mutation paths for the feed
  • Cache keys that need a second review
  • Streaming makes a few tests asynchronous

A short checklist#

This is the list we run before merging anything that touches a data-fetching route. It is short on purpose. If a change cannot pass it, the design is not finished yet.

Before you merge

  • The primary content renders from one document request.
  • Independent reads are batched, not chained.
  • Authorization lives in the data layer, not the component.
  • The shell has a measured budget and a test that guards it.
  • Only personalized regions stream behind Suspense.
  • Every skeleton matches the final block's exact height.

Key takeaways

  1. 1Measure time-to-readable, not API latency.
  2. 2Serialize only the reads that truly depend on the previous value.
  3. 3Move authorization into the data layer before moving reads to the server.
  4. 4Give the shell a budget and let personalization stream behind it.
LV

Written by

Lina Voss

Principal engineer on the platform team. Writes about browser performance, rendering budgets and the unglamorous parts of shipping software.

All essays

The next essay

One long read, every other Thursday.

Field notes from engineers who measure before they rewrite. No news roundups, no growth hacks.

11,400 engineers read it. No spam, ever.