Avoiding Sequential Async Waterfalls
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 9, 2026
TL;DR
Start independent asynchronous work as early as possible. Await only when later work actually depends on an earlier result or when ordering is intentional.
await suspends the continuation of the surrounding async function until the awaited value is ready. It does not block the JavaScript thread. A waterfall appears when code waits before it has started another operation that could have been running independently.
Do not mechanically replace sequential awaits with Promise.all(). First identify which operations actually depend on earlier results, overlap independent operations, and bound concurrency when resource pressure requires it.
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.
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.
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
Concurrency has costs. Removing a waterfall can make one request faster while placing more simultaneous pressure on other resources.
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, ask these questions before changing scheduling:
- Does operation B need A's result, or is the ordering accidental?
- Are there side effects or error semantics that make overlap unsafe?
- Could the proposed fan-out overwhelm a bounded resource?
- Is there independent work that can start while a required dependency chain is running?
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.
HTTP Request Lifecycle: From URL to Response
Trace how an HTTP request can use caches, reused connections, intermediaries, and origins without confusing HTTP semantics with network setup.
How the Browser Event Loop Actually Works
Understand run-to-completion, tasks, microtasks, rendering opportunities, and why Node.js has a different scheduling model.