{"id":"e7dq8jrhxqxb37g","title":"Fetch Waterfalls: Why Parallel Requests Still Feel Slow","slug":"fetch-waterfalls-parallel-requests","summary":"Parallel fetch is not enough if chains start late. I flatten dependencies, colocate data, and cache at the edge.","imageUrl":"https://briancrabtree.me/images/journal-fetch-waterfalls-parallel-requests.webp","category":"JavaScript","date":"2026-05-11T18:00:00.000Z","featured":false,"likes":16,"author":"Brian Crabtree","content":"<h2>Waterfall versus parallel</h2>\n\n<p>Waterfall means request B starts only after A completes. Parallel means simultaneous starts. SPAs often waterfall inside useEffect chains: load user, then load settings, then load dashboard. Even with HTTP/2, latency adds serially.</p>\n\n<p>Network panel shows green parallel bars; architecture can still be serial logic.</p>\n\n<p>DNS prefetch and preconnect for API host on HTML shell saves RTT on first client fetch after hydration. GraphQL DataLoader pattern on server removes N+1 behind single query appearance.</p>\n\n<pre><code>const [user, posts] = await Promise.all([\n  fetch('/api/user').then((r) =&gt; r.json()),\n  fetch('/api/posts').then((r) =&gt; r.json()),\n]);</code></pre>\n\n<h2>Root causes in frontends</h2>\n\n<p>Nested components each fetch on mount. Parent passes id down, child fetches details. I lift fetches to route loaders or one parent with Promise.all. GraphQL without batching resolvers can N+1 the server while looking one query on wire.</p>\n\n<p>Await in loops is the async sin. Map to concurrent promises or server-side join.</p>\n\n<p>HTTP/3 changes connection behavior; serial chains still hurt latency math regardless of protocol. Browser connection limit per host still matters on HTTP/1.1 legacy proxies.</p>\n\n<h2>Backend joins beat chatty clients</h2>\n\n<p>One endpoint returning dashboard DTO beats six microservices called from browser unless CDN caches each independently with long TTL. BFF aggregates for read-heavy views.</p>\n\n<p>Prefetch on link hover helps secondary navigation when aggregation is impossible short term.</p>\n\n<p>Service worker stale-while-revalidate can mask waterfall on repeat visits but first visit still matters for SEO. Retry with exponential backoff on 503 must not amplify thundering herd on recovering API.</p>\n\n<h2>Caching layers</h2>\n\n<p>Stale-while-revalidate in service workers or CDN for semi-static JSON. SWR and TanStack Query dedupe in-flight requests on client. Without cache, parallel refetch on remount wastes bandwidth.</p>\n\n<p>Auth endpoints opt out; public config and feature flags can cache short.</p>\n\n<p>Batching REST with _batch query param is ugly but worked on legacy APIs I could not merge. Client-side ORM cache is not substitute for server join when privacy scopes differ per role.</p>\n\n<h2>HTTP details that matter</h2>\n\n<p>Early hints and preconnect for API origins on marketing pages that call CMS. Compression bro preferred. Keep cookies off API subdomain when possible to shrink headers.</p>\n\n<p>Pagination defaults that return huge JSON payloads hurt more than waterfall sometimes; trim fields.</p>\n\n<p>OpenTelemetry trace across browser fetch and server span shows serial gaps clearly for stakeholder demos. Upload progress bars need XHR or fetch stream; parallel download does not help uploads.</p>\n\n<h2>Static and build-time wins</h2>\n\n<p>If data changes hourly, fetch at build and revalidate on webhook. Zero client waterfall on first paint. This site prefers static journal HTML plus enhancement for that reason.</p>\n\n<p>Mobile networks penalize many small JSON calls more than one medium payload on high latency links.</p>\n\n<p>Speculation rules API prerender on hover is emerging option for doc sites with stable URLs.</p>\n\n<h2>Audit</h2>\n\n<p>Export HAR from slow load. I mark serial chains in red and propose one aggregated endpoint or loader refactor. Waterfalls are logic bugs visible in DevTools if you know where to look.</p>\n\n<p>I set fetch priority high only for true critical JSON, low for analytics config.</p>\n\n<p>Waterfall in SSR data loaders shows in server logs as serial await; fix there first. For a related angle I keep coming back to, see <a href=\"/journal/api-rate-limiting-frontend-patterns/\">API Rate Limiting Patterns Frontends Depend On</a>.</p>","tags":["fetch","waterfall","network"],"views":59}