Async Waterfalls: Overlap Independent Work Without Breaking Dependencies
Reduce latency by overlapping independent asynchronous work without breaking real dependencies.
Personal learning atlas by Tran Trong Thuc · About this Atlas · Atlas last updated Sep 22, 2026
TL;DR
Imagine a dashboard page loader that fetches four independent pieces of data: user profile, feature flags, permissions, and notifications. Each API responds in 100ms. Placed behind sequential await statements, those four calls form a 400ms waterfall—quadrupling server response times and tanking Core Web Vitals (LCP and TTFB). None of those requests needed data from the others, yet control flow forced them into a single file line. Initiating independent work concurrently via Promise.all() or separating promise instantiation from awaiting collapses total latency back to the single slowest request: ~100ms.
💡 Rule of thumb: Decouple promise creation from promise awaiting. Trigger all independent asynchronous operations as early as possible, and only
awaitat the exact point where subsequent code genuinely consumes that specific result.
awaitpauses function continuation, not the thread: Placingawaiton an independent promise idles the caller function while network and CPU resources sit unused instead of running concurrent work.- Decouple invocation from awaiting: Invoking
const p = fetch(...)dispatches the network request immediately; awaitingplater retrieves the outcome. Always fire independent operations upfront before awaiting. - Overlapping collapses latency to the critical path: Concurrent execution reduces total duration from
sum(durations)to approximatelymax(durations), eliminating artificial sequential delays. Promise.allfails fast without canceling siblings: When one input rejects,Promise.allrejects immediately, but already-initiated sibling operations keep running in the background unless aborted viaAbortSignal.- Fatal pitfall (Unbounded Fan-out Outage): Replacing sequential loops with
await Promise.all(items.map(fn))across large batches triggers massive concurrent connection bursts that saturate sockets, exhaust database pools, and trigger cascading microservice outages. Always bound concurrency when fan-out is large or dynamic.
Mental model
Start with a concrete schedule. Suppose three independent operations take 800ms, 400ms, and 300ms.
If each starts only after the previous one finishes:
A: 0 ───────────── 800
B: 800 ───── 1200
C: 1200 ─── 1500Elapsed time is about 800 + 400 + 300 = 1500ms.
If all three can start immediately:
A: 0 ───────────── 800
B: 0 ───── 400
C: 0 ─── 300Elapsed time is about max(800, 400, 300) = 800ms. The waiting periods overlap, saving roughly 700ms in this simplified model.
Sequential: ~1500 ms
Overlapped: ~800 ms
A useful question is:
What information must exist before this operation can start?
If the answer is “nothing produced by an earlier operation,” the operation may be able to start sooner.
This lesson is about concurrent, overlapped asynchronous work. The rule is simply: do not delay the start of independent work because the current async function is waiting for an unrelated result.
Why waterfalls happen
Sequential code is easy to read, so accidental waterfalls often look perfectly reasonable:
const user = await getUser(id);
const flags = await getFeatureFlags(id);
const recommendations = await getRecommendations(id);Suppose all three functions only need id. The result of getUser() is not needed to start getFeatureFlags(), and neither result is needed to start getRecommendations().
Yet the code says:
- start
getUser()and wait for its result; - only then start
getFeatureFlags()and wait; - only then start
getRecommendations()and wait.
The dependency graph says the operations are independent, but the control flow has imposed dependencies that do not exist in the data flow.
Waterfalls commonly appear in request handlers, page loaders, server-rendering code, command-line workflows, build steps, and service-to-service calls. They are especially costly on latency-sensitive paths because every unnecessary wait can lengthen the chain of work that must finish before the caller gets a result.
A real accidental waterfall
Consider a request that needs three independent pieces of information:
export async function buildHomePage(id: string) {
const user = await getUser(id);
const flags = await getFeatureFlags(id);
const recommendations = await getRecommendations(id);
return { user, flags, recommendations };
}There is nothing inherently wrong with using await here. The problem is when the calls start.
If the three calls take 800ms, 400ms, and 300ms, this implementation takes roughly 1500ms before accounting for application overhead.
That extra latency is not buying correctness. It is only a consequence of starting independent work late.
Start independent work early
Start each independent operation before awaiting earlier results:
export async function buildHomePage(id: string) {
const userPromise = getUser(id);
const flagsPromise = getFeatureFlags(id);
const recommendationsPromise = getRecommendations(id);
const [user, flags, recommendations] = await Promise.all([
userPromise,
flagsPromise,
recommendationsPromise,
]);
return { user, flags, recommendations };
}All three operations are started before the function waits for their combined results.
Promise.all() is convenient here because the function needs all three values before it can return. But the deeper rule is not “use Promise.all().” The deeper rule is start independent work before an unrelated wait delays it.
Sometimes that means starting one promise early and awaiting it much later:
const flagsPromise = getFeatureFlags(id);
const user = await getUser(id);
const organization = await getOrganization(user.organizationId);
const flags = await flagsPromise;
return { user, organization, flags };getOrganization() genuinely depends on user, while getFeatureFlags() does not. Starting the flags request early allows that independent wait to overlap with the user → organization dependency chain.
Try it: Async Waterfall Lab
The lab below compares the same three operation durations on one shared elapsed-time scale.
With the defaults:
- sequential:
800 + 400 + 300 = 1500ms; - concurrent:
max(800, 400, 300) = 800ms; - elapsed time saved:
700ms.
The exact start, duration, and end values are shown in tables as well as visual bars. The animation is optional; the timing model does not depend on it.
Async Waterfall Lab
Sequential
| Task | Start | Duration | End |
|---|---|---|---|
| A | 0ms | 800ms | 800ms |
| B | 800ms | 400ms | 1200ms |
| C | 1200ms | 300ms | 1500ms |
Sequential total: 1500ms
Concurrent
| Task | Start | Duration | End |
|---|---|---|---|
| A | 0ms | 800ms | 800ms |
| B | 0ms | 400ms | 400ms |
| C | 0ms | 300ms | 300ms |
Concurrent total: 800ms
Dependencies change the answer
Not every sequence is an accidental waterfall.
const user = await getUser(id);
const organization = await getOrganization(user.organizationId);getOrganization() needs user.organizationId, so it cannot start until getUser() has produced that value. The ordering represents a real data dependency.
The useful optimization question becomes: what else can overlap with this dependency chain?
const flagsPromise = getFeatureFlags(id);
const recommendationsPromise = getRecommendations(id);
const user = await getUser(id);
const organization = await getOrganization(user.organizationId);
const [flags, recommendations] = await Promise.all([
flagsPromise,
recommendationsPromise,
]);Here the user → organization path remains sequential, while unrelated work starts immediately.
Critical path: 900 ms (Sequential)
Independent branches (Run concurrently from t=0)
Promise.all() is a tool, not the rule
Promise.all(iterable) returns a promise that fulfills when all of its inputs fulfill. The fulfillment values are returned in the same order as the inputs, regardless of which input settles first.
The aggregate promise rejects when an input rejects. That aggregate rejection can happen before the other inputs settle, so application code may observe the Promise.all() result as rejected while other already-started operations are still running.
There is an important operational detail: rejection of the Promise.all() result does not automatically cancel other operations that already started. Those operations may continue unless their underlying APIs provide cancellation and your code explicitly uses it.
For example:
const profilePromise = getProfile(id);
const auditPromise = writeAuditEntry(id);
const [profile] = await Promise.all([
profilePromise,
auditPromise,
]);If getProfile() rejects, that does not mean an already-started writeAuditEntry() is automatically stopped. This matters when concurrent operations have side effects.
If you need to observe every independent outcome—even when some fail—that is a different error-handling requirement. A combinator such as Promise.allSettled() may be relevant, but changing error semantics should be a deliberate decision rather than a side effect of trying to reduce latency.
Use Promise.all() when its aggregation and failure semantics match the work. Do not treat it as a magic concurrency switch.
Production considerations
Production scenario: The unbounded fan-out outage
An analytics reporting service needed to fetch transaction history for 2,000 active team members. Noticing that a sequential for...await loop was sluggish, a developer replaced it with await Promise.all(members.map(fetchHistory)).
In staging with 10 test users, the batch completed in a blistering 45ms. But in production with 2,000 active users, it instantly opened 2,000 simultaneous TCP sockets, exhausted the database connection pool, and knocked out the internal authentication microservice with ECONNRESET, cascading outages across all dependent platform services.
- Impact: Widespread internal service crash and an immediate 5xx error spike on customer-facing APIs.
- Root cause: The developer conflated minimizing single-request latency with aggregate system capacity, trading a slow sequential loop for unbounded fan-out that overwhelmed downstream infrastructure.
- Correct pattern: Enforce bounded concurrency using a worker pool, batching, or a concurrency limiter (such as
p-limit):import pLimit from 'p-limit'; const limit = pLimit(15); const results = await Promise.all( members.map((member) => limit(() => fetchHistory(member))) );
Concurrency has costs. Removing a waterfall can make one request faster while placing more simultaneous pressure on other resources.
- 1,000 queued jobsIndependent work
- Semaphore: 5 slotsAdmission control
- 5 active workersBounded fan-out
- Database / APIStable downstream load
Bound fan-out when necessary. Starting three independent calls is different from starting 50,000. Large fan-out may exhaust database connection pools, sockets, file descriptors, memory, CPU, or downstream service capacity. When the set is large, use a concurrency limit, queue, worker pool, batching strategy, or another form of deliberate backpressure.
Respect downstream rate limits. Overlapping requests changes arrival patterns. A remote API that comfortably handles ten sequential calls may reject or throttle ten simultaneous calls. The schedule with the lowest latency for one caller is not automatically the best schedule for the whole system.
Think about side effects and errors. Once several operations have started, one failure may occur after others have already changed state. Design idempotency, transactions, compensation, or failure isolation according to the domain rather than assuming promise aggregation provides those guarantees.
Cancellation is separate. Some APIs support cancellation—for example through an abort signal—but Promise.all() itself does not retroactively stop the work represented by its inputs. Cancellation deserves its own lifecycle and ownership design.
Measure the real path. The simple sum versus max model is useful for understanding independent waits, but real systems include queueing, connection setup, caching, contention, retries, scheduling overhead, and partial dependencies. Profile or trace the path before making large architectural changes.
When sequential execution is correct
Sequential execution is not a code smell by itself. Preserve it when ordering is part of correctness or deliberate system control.
Examples include:
- a later operation needs data produced by an earlier operation;
- writes must happen in a defined order;
- a transaction protocol requires a sequence of steps;
- the system intentionally applies backpressure;
- a shared resource cannot safely handle concurrent access;
- an operation should only run after a previous validation or authorization succeeds;
- later work would be wasteful or harmful if an earlier step fails.
The goal is not maximum concurrency. The goal is to remove false dependencies while preserving real ones.
Exercise
A request needs four operations:
| Operation | Duration | Dependency |
|---|---|---|
getUser(id) | 500ms | none |
getFeatureFlags(id) | 300ms | none |
getRecommendations(id) | 700ms | none |
getOrganization(user.organizationId) | 400ms | getUser |
Before reading the answer, decide:
- Which operations can start immediately?
- Which operation must wait?
- What is the approximate best-case elapsed time if the independent work overlaps?
Show the reasoning
getUser, getFeatureFlags, and getRecommendations can start immediately.
getOrganization must wait for getUser because it needs user.organizationId.
The important paths are:
- user → organization:
500 + 400 = 900ms; - feature flags:
300ms; - recommendations:
700ms.
The critical path is therefore about 900ms. A fully sequential implementation would take about 500 + 300 + 700 + 400 = 1900ms.
Notice that this is not equivalent to putting all four calls into one Promise.all(): the organization request cannot be constructed correctly until the user result exists.
Agent rule
When asynchronous operations are independent, start them before awaiting unrelated earlier results. Preserve sequential awaits when later work depends on earlier results or when ordering/backpressure is intentional. Do not introduce unbounded concurrency merely to reduce latency, and do not assume
Promise.all()rejection cancels already-started work.
When reviewing code, use this checklist before changing scheduling:
- Accidental vs. real dependency: Does operation B actually need operation A's return value, or is sequential ordering merely accidental?
- Side effects & isolation: Are there state mutations or database write constraints that make concurrent execution unsafe?
- Fan-out bounds: Could the number of concurrent operations overwhelm network sockets, connection pools, or downstream API rate limits?
- Overlapping opportunities: Is there independent work that can start immediately while a required dependency chain is running?
- Cancellation & early exit: What happens if one operation fails—should in-flight requests be cancelled via
AbortSignal?
Related concepts
- Promise aggregation and
Promise.all() - Promise settlement and
Promise.allSettled() - cancellation and
AbortSignal - bounded concurrency and backpressure
- critical paths and dependency graphs
- latency versus throughput
These graph edges will become direct Atlas lesson links as the related lessons are added.
Sources
This lesson was last verified on 2026-09-09 and is classified as evolving with a 180-day review target.
- ECMAScript 2026 —
Promise.all— normative Promise aggregation and result-order semantics. - MDN —
await— developer-facing explanation thatawaitsuspends the surrounding async function continuation without blocking the main thread. - MDN —
Promise.all()— readable reference for aggregation, fulfillment ordering, and rejection behavior. - MDN — Using promises — Promise composition guidance and concurrency patterns.
Verification Loops & Machine-Checkable GuardrailsNew
Turn engineering intent into fresh evidence by combining explicit acceptance criteria, fast feedback, repository gates, and guardrails that agents cannot satisfy by assertion alone.
Browser Event Loop: How Tasks, Microtasks, and Rendering Are Scheduled
Understand run-to-completion, tasks, microtasks, rendering opportunities, and why Node.js has a different scheduling model.