Sequential Awaits Are Silently Slowing Down Your API
Awaiting four independent API calls in sequence instead of parallel can triple your endpoint latency. Here's the pattern, the math, and how to catch it in review.
Sequential Awaits Are Silently Slowing Down Your API
You're reviewing a PR. The diff is clean — proper TypeScript, good variable names, no obvious bugs. You're about to approve when you notice this:
const user = await getUser(userId);
const orders = await getOrders(userId);
const analytics = await getAnalytics(userId);
const recommendations = await getRecommendations(userId);Four lines. Each one correct. All four together? A performance disaster hiding behind readable code.
The Mistake
When developers learn async/await, they write code that looks synchronous — and that's the point. The problem is the mental model doesn't always translate. Seeing await before every call makes it feel natural, complete, correct. But those four calls above run one after another, even though none of them depend on the previous result. Every call is waiting for the one before it to finish for no reason.
This is called the sequential waterfall. And it's one of the most common performance bugs that slips through code review.
Here's what the timing looks like:
Sequential:
getUser ████████ 200ms
getOrders ████████████ 300ms
getAnalytics ████████ 200ms
getRecommendations ████████████████ 400ms
Total: 1100ms
Parallel (Promise.all):
getUser ████████ 200ms
getOrders ████████████ 300ms
getAnalytics ████████ 200ms
getRecommendations ████████████████ 400ms
Total: 400msSame four operations. Sequential: 1.1 seconds. Parallel: 400ms. The wall clock time drops to the slowest single operation.
That's not a back-of-envelope figure — it's what you get if you actually run it with those four delays on Node 22.22.3:
sequential = 1118ms
parallel = 406ms (2.75x, 712ms saved)The speedup is bounded by slowest / total, so it gets better the more evenly matched your calls are and worse the more one of them dominates. Four identical 250ms calls is a 4x win. One 900ms call plus three 20ms calls is barely worth the refactor.
On a dashboard endpoint that gets called hundreds of times a minute, that 700ms difference is not a rounding error. It shows up in your p95, in your Core Web Vitals, and in the bounce rate of users who give up before the page loads.
Why It Happens
The await keyword suspends the current function until the promise resolves. When you await something, you're telling JavaScript: stop here, do nothing else in this function, wait for this to come back. That's correct behavior — when the next operation depends on the result of the current one.
The mistake is using await when there's no dependency. getOrders(userId) doesn't need the result of getUser(userId) to run. It just needs userId, which you already have. Awaiting getUser first is pure waste.
Example 1: Dashboard API Endpoint
This is the most common place to find the pattern. A dashboard loads profile data, recent activity, and some aggregated stats.
Before:
// GET /api/dashboard
export async function GET(req: Request) {
const userId = getUserId(req);
const user = await db.user.findUnique({ where: { id: userId } });
const recentOrders = await db.order.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
take: 5,
});
const totalSpent = await db.order.aggregate({
where: { userId, status: "COMPLETED" },
_sum: { amount: true },
});
const unreadNotifications = await db.notification.count({
where: { userId, read: false },
});
return Response.json({ user, recentOrders, totalSpent, unreadNotifications });
}If each query takes ~80ms against a local Postgres instance, this endpoint takes 320ms minimum before any serialization overhead. Under load, with connection pooling pressure, it gets worse.
After:
export async function GET(req: Request) {
const userId = getUserId(req);
const [user, recentOrders, totalSpent, unreadNotifications] =
await Promise.all([
db.user.findUnique({ where: { id: userId } }),
db.order.findMany({
where: { userId },
orderBy: { createdAt: "desc" },
take: 5,
}),
db.order.aggregate({
where: { userId, status: "COMPLETED" },
_sum: { amount: true },
}),
db.notification.count({ where: { userId, read: false } }),
]);
return Response.json({ user, recentOrders, totalSpent, unreadNotifications });
}Now all four queries fire simultaneously. The endpoint's latency drops to the slowest single query — typically ~80ms instead of ~320ms.
Example 2: Sending Notifications to Multiple Users
Fan-out operations are another place this pattern appears constantly.
Before:
async function notifyUsersAboutRelease(userIds: string[], version: string) {
for (const userId of userIds) {
await sendEmail(userId, `Version ${version} is live`);
await sendPushNotification(userId, `New release: ${version}`);
await createInAppNotification(userId, version);
}
}For 100 users, if each operation takes 50ms, this loops through 300 sequential operations: 15 seconds. The for loop serializes everything — users are notified one at a time, in order.
After:
async function notifyUsersAboutRelease(userIds: string[], version: string) {
await Promise.all(
userIds.map((userId) =>
Promise.all([
sendEmail(userId, `Version ${version} is live`),
sendPushNotification(userId, `New release: ${version}`),
createInAppNotification(userId, version),
])
)
);
}All notifications for all users fire concurrently. The job finishes in roughly 50ms (the time of one operation) plus network overhead, regardless of user count — until you hit rate limits. Which is the next problem.
When parallelizing fan-out across many items, you may hit external rate limits or exhaust your connection pool. Use a concurrency limiter like p-limit to cap parallel inflight requests to a sane number (10–20 is usually safe).
import pLimit from "p-limit";
const limit = pLimit(15); // max 15 concurrent
async function notifyUsersAboutRelease(userIds: string[], version: string) {
await Promise.all(
userIds.map((userId) =>
limit(() =>
Promise.all([
sendEmail(userId, `Version ${version} is live`),
sendPushNotification(userId, `New release: ${version}`),
createInAppNotification(userId, version),
])
)
)
);
}Example 3: Multi-Source Data Aggregation
Fetching data from multiple external APIs — a payments provider, a CRM, and an analytics service — to build a unified customer record.
Before:
async function buildCustomerProfile(customerId: string) {
const stripeCustomer = await stripe.customers.retrieve(customerId);
const crmContact = await hubspot.crm.contacts.basicApi.getById(customerId);
const usageStats = await amplitude.getUserActivity(customerId);
const supportTickets = await zendesk.tickets.list({ userId: customerId });
return mergeCustomerData(stripeCustomer, crmContact, usageStats, supportTickets);
}Four external HTTP calls, each with 150–400ms of latency. Sequential total: 600ms–1.6s per call to this function.
After:
async function buildCustomerProfile(customerId: string) {
const [stripeCustomer, crmContact, usageStats, supportTickets] =
await Promise.all([
stripe.customers.retrieve(customerId),
hubspot.crm.contacts.basicApi.getById(customerId),
amplitude.getUserActivity(customerId),
zendesk.tickets.list({ userId: customerId }),
]);
return mergeCustomerData(stripeCustomer, crmContact, usageStats, supportTickets);
}The total latency is now bounded by the slowest single API, typically 400ms. That's a 4x improvement with one refactor.
Example 4: The "Staircase" in Next.js Server Components
Next.js server components made data fetching composable and ergonomic. They also made it easy to create waterfalls inside a single component.
Before:
// app/dashboard/page.tsx
export default async function DashboardPage() {
const session = await getSession();
const user = await getUser(session.userId);
const courses = await getEnrolledCourses(session.userId);
const progress = await getLearningProgress(session.userId);
return <Dashboard user={user} courses={courses} progress={progress} />;
}Each await suspends the server render until that fetch completes. The page TTFB reflects the sum of all four latencies.
After:
export default async function DashboardPage() {
const session = await getSession(); // this one IS sequential — we need the userId
const [user, courses, progress] = await Promise.all([
getUser(session.userId),
getEnrolledCourses(session.userId),
getLearningProgress(session.userId),
]);
return <Dashboard user={user} courses={courses} progress={progress} />;
}getSession() stays sequential because we need session.userId before we can fire the other three. That's a legitimate dependency. The other three have no dependency on each other, so they run in parallel.
This is the key mental check: does the next call need data from the previous one? If yes, it must be sequential. If no, it shouldn't be.
When Promise.all Isn't Enough: allSettled
Promise.all fails fast — if any promise rejects, the whole thing throws and you lose the results of the ones that succeeded. For non-critical fan-out operations where partial success is acceptable, use Promise.allSettled.
async function enrichUserProfiles(userIds: string[]) {
const results = await Promise.allSettled(
userIds.map((id) => fetchEnrichmentData(id))
);
const enriched = results
.filter(
(r): r is PromiseFulfilledResult<EnrichmentData> =>
r.status === "fulfilled"
)
.map((r) => r.value);
const failed = results
.filter((r): r is PromiseRejectedResult => r.status === "rejected")
.length;
if (failed > 0) {
logger.warn(`Enrichment failed for ${failed}/${userIds.length} users`);
}
return enriched;
}allSettled always resolves — you inspect each result individually and handle failures where it matters, rather than letting one bad response throw away everything else.
The difference is timing, not just error handling. Given one promise that resolves at 150ms and one that rejects at 30ms:
Promise.all -> rejected at 35ms (abandons the slow one mid-flight)
Promise.allSettled -> resolved at 150ms (waits for both, reports both)Promise.all rejecting early doesn't cancel the others — nothing in JavaScript cancels a promise. They keep running to completion in the background, their results discarded. That's fine for reads and dangerous for writes: if two of four inserts had already landed when the third threw, Promise.all gives you a rejection and a half-written database. It does at least attach a handler to every member, so a later rejection from a sibling won't crash the process as an unhandled rejection — verified on Node 22.22.3.
How to Catch This in Code Review
Look for these patterns in every async function:
Multiple sequential awaits on the same line level with no data dependency between them:
// Red flag
const a = await fetchA(id);
const b = await fetchB(id); // doesn't use `a`
const c = await fetchC(id); // doesn't use `a` or `b`await inside a for loop when order doesn't matter:
// Red flag — unless the loop body depends on the previous iteration's result
for (const item of items) {
await processItem(item);
}Awaits that reference the same input data (e.g. userId) but not each other's output.
When you see this pattern, ask: "Does line N+1 need the resolved value from line N?" If the answer is no for two or more consecutive awaits, they should be parallelized.
For the loop case, the fix is usually Promise.all(items.map(...)). Add p-limit if the array could be large or if you're calling external services.
The staircase pattern in browser DevTools or distributed tracing (spans going one after another instead of overlapping) is the runtime signal. Sequential awaits create it. Promise.all collapses it.
If two async operations don't need each other's results, awaiting them one after another is a bug — it's just a bug that never throws.
Comments (0)
No comments yet. Be the first to share your thoughts!