A promise waterfall runs an array of async functions in sequence, passing each function's resolved result as the input to the next, and resolves with the value the last function returns. The name is the picture: a value falls from one task straight down into the next — never sideways — so only one task runs at a time. It is a well-known composition pattern; the popular async library ships it as async.waterfall.
type Task = (value: any) => any | Promise<any>;
// Runs tasks left-to-right: task[0] receives initialValue, and each later task
// receives the previous task's resolved value. Resolves with the final value.
function promiseWaterfall(tasks: Task[], initialValue: unknown): Promise<unknown>;
// Each task transforms the running value; the chain resolves with the last one.
promiseWaterfall([(n) => n + 1, (n) => n * 2, (n) => n - 3], 5)
.then(console.log); // 9 — ((5 + 1) * 2) - 3
// Tasks can be async. Each task waits for the previous promise to resolve.
const loadUser = (id) => Promise.resolve({ id, name: 'Ada' });
const nameOf = (user) => user.name.toUpperCase();
promiseWaterfall([loadUser, nameOf], 42).then(console.log); // 'ADA'
// An empty task list resolves with the initial value, untouched.
promiseWaterfall([], 'seed').then(console.log); // 'seed'
// A rejection stops the chain — later tasks never run.
promiseWaterfall([(n) => n + 1, () => Promise.reject('boom'), (n) => n * 2], 0)
.catch(console.log); // 'boom' (the * 2 task never runs)
Promise.all, which starts everything at once.task[i] receives whatever task[i - 1] resolved to. Only task[0] sees initialValue..then or await the result.You'll run a list of functions like an assembly line: each one takes the previous one's output and hands its own output to the next, one at a time.
Say you're handling a signup: first look up a user by id, then load their profile, then format a welcome line. Each step needs the result of the step before it, and a step might be async — a network call that returns a promise. You can't fire these all at once, because step two literally doesn't have its input until step one finishes. A waterfall runs them in order, feeds each result forward into the next, and stops the moment one step fails.
Picture water falling down a set of ledges. The value starts at the top as initialValue, drops into task[0], and whatever that resolves to drops into task[1], and so on. Nothing moves sideways — only one ledge is active at a time — and the value that lands at the bottom is what the waterfall resolves with.
A tempting first move is to reach for Promise.all with map, the way you would to run independent things together:
function promiseWaterfallBroken(tasks, initialValue) {
return Promise.all(tasks.map((task) => task(initialValue)));
}
This is wrong in three ways. Promise.all starts every task at the same moment, so they run concurrently instead of one at a time. Every task is called with initialValue, so one task's result never reaches the next — there is no threading. And it resolves with an array of every task's result, not the single final value. map plus Promise.all is the tool for "do these independent things together"; a waterfall is the opposite — each step depends on the one before it.
To thread the value through, keep a running value and await each task in turn inside a normal for loop. The await is what forces "one at a time": the loop cannot move to the next task until the current task's promise has resolved.
async function promiseWaterfall(tasks, initialValue) {
let value = initialValue; // the running value that falls from task to task
for (const task of tasks) {
// await pauses the loop until this task settles, so tasks never overlap.
// If the task throws or its promise rejects, the error propagates out of
// this async function and the remaining tasks are never run.
value = await task(value);
}
return value; // the last task's result — or initialValue if there were none
}
module.exports = { promiseWaterfall };
Three things fall out of this shape for free. Marking the function async means it always returns a promise, even for an empty list. await task(value) handles sync and async tasks identically, because awaiting a plain value just resolves to that value. And because there is no try/catch, the first rejection (or throw) bubbles straight up and aborts the loop, so later tasks are skipped exactly as required.
Take promiseWaterfall([(n) => n + 1, (n) => n * 2, (n) => n - 3], 5):
value = 5 (the initialValue).task is (n) => n + 1. await task(5) resolves to 6, so value = 6.task is (n) => n * 2. await task(6) resolves to 12, so value = 12.task is (n) => n - 3. await task(12) resolves to 9, so value = 9.return 9, and the waterfall resolves with 9.Now swap the middle task for one that rejects: [(n) => n + 1, () => Promise.reject('boom'), (n) => n * 2]. Iteration 1 sets value = 1. Iteration 2 runs await task(1), which rejects with 'boom' — the await throws, the loop stops, and promiseWaterfall rejects with 'boom'. The * 2 task is never called.
Promise.all — it runs tasks in parallel and feeds every task the same input. A waterfall needs sequence and threading; use an awaited loop (or a reduce chain), never Promise.all.await inside the loop — value = task(value) with no await stores a pending promise in value, then passes that promise as the next task's input. The next task receives a Promise object instead of the resolved value. Always await.try/catch — if you catch a failure and let the loop continue, later tasks run after a failure, which breaks the "stop on the first error" contract. Let the rejection propagate; don't catch it inside the loop.await on a non-promise resolves to that value on the next microtask, so the same line handles both; you don't need to sniff whether the return has a .then.reduce instead of a loop. The same waterfall is often written point-free as tasks.reduce((chain, task) => chain.then(task), Promise.resolve(initialValue)). It builds the exact .then chain the awaited loop runs; pick whichever reads clearer to you.AbortSignal and check signal.aborted at the top of each iteration, rejecting early to stop a long waterfall the caller no longer needs.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
A promise waterfall runs an array of async functions in sequence, passing each function's resolved result as the input to the next, and resolves with the value the last function returns. The name is the picture: a value falls from one task straight down into the next — never sideways — so only one task runs at a time. It is a well-known composition pattern; the popular async library ships it as async.waterfall.
type Task = (value: any) => any | Promise<any>;
// Runs tasks left-to-right: task[0] receives initialValue, and each later task
// receives the previous task's resolved value. Resolves with the final value.
function promiseWaterfall(tasks: Task[], initialValue: unknown): Promise<unknown>;
// Each task transforms the running value; the chain resolves with the last one.
promiseWaterfall([(n) => n + 1, (n) => n * 2, (n) => n - 3], 5)
.then(console.log); // 9 — ((5 + 1) * 2) - 3
// Tasks can be async. Each task waits for the previous promise to resolve.
const loadUser = (id) => Promise.resolve({ id, name: 'Ada' });
const nameOf = (user) => user.name.toUpperCase();
promiseWaterfall([loadUser, nameOf], 42).then(console.log); // 'ADA'
// An empty task list resolves with the initial value, untouched.
promiseWaterfall([], 'seed').then(console.log); // 'seed'
// A rejection stops the chain — later tasks never run.
promiseWaterfall([(n) => n + 1, () => Promise.reject('boom'), (n) => n * 2], 0)
.catch(console.log); // 'boom' (the * 2 task never runs)
Promise.all, which starts everything at once.task[i] receives whatever task[i - 1] resolved to. Only task[0] sees initialValue..then or await the result.You'll run a list of functions like an assembly line: each one takes the previous one's output and hands its own output to the next, one at a time.
Say you're handling a signup: first look up a user by id, then load their profile, then format a welcome line. Each step needs the result of the step before it, and a step might be async — a network call that returns a promise. You can't fire these all at once, because step two literally doesn't have its input until step one finishes. A waterfall runs them in order, feeds each result forward into the next, and stops the moment one step fails.
Picture water falling down a set of ledges. The value starts at the top as initialValue, drops into task[0], and whatever that resolves to drops into task[1], and so on. Nothing moves sideways — only one ledge is active at a time — and the value that lands at the bottom is what the waterfall resolves with.
A tempting first move is to reach for Promise.all with map, the way you would to run independent things together:
function promiseWaterfallBroken(tasks, initialValue) {
return Promise.all(tasks.map((task) => task(initialValue)));
}
This is wrong in three ways. Promise.all starts every task at the same moment, so they run concurrently instead of one at a time. Every task is called with initialValue, so one task's result never reaches the next — there is no threading. And it resolves with an array of every task's result, not the single final value. map plus Promise.all is the tool for "do these independent things together"; a waterfall is the opposite — each step depends on the one before it.
To thread the value through, keep a running value and await each task in turn inside a normal for loop. The await is what forces "one at a time": the loop cannot move to the next task until the current task's promise has resolved.
async function promiseWaterfall(tasks, initialValue) {
let value = initialValue; // the running value that falls from task to task
for (const task of tasks) {
// await pauses the loop until this task settles, so tasks never overlap.
// If the task throws or its promise rejects, the error propagates out of
// this async function and the remaining tasks are never run.
value = await task(value);
}
return value; // the last task's result — or initialValue if there were none
}
module.exports = { promiseWaterfall };
Three things fall out of this shape for free. Marking the function async means it always returns a promise, even for an empty list. await task(value) handles sync and async tasks identically, because awaiting a plain value just resolves to that value. And because there is no try/catch, the first rejection (or throw) bubbles straight up and aborts the loop, so later tasks are skipped exactly as required.
Take promiseWaterfall([(n) => n + 1, (n) => n * 2, (n) => n - 3], 5):
value = 5 (the initialValue).task is (n) => n + 1. await task(5) resolves to 6, so value = 6.task is (n) => n * 2. await task(6) resolves to 12, so value = 12.task is (n) => n - 3. await task(12) resolves to 9, so value = 9.return 9, and the waterfall resolves with 9.Now swap the middle task for one that rejects: [(n) => n + 1, () => Promise.reject('boom'), (n) => n * 2]. Iteration 1 sets value = 1. Iteration 2 runs await task(1), which rejects with 'boom' — the await throws, the loop stops, and promiseWaterfall rejects with 'boom'. The * 2 task is never called.
Promise.all — it runs tasks in parallel and feeds every task the same input. A waterfall needs sequence and threading; use an awaited loop (or a reduce chain), never Promise.all.await inside the loop — value = task(value) with no await stores a pending promise in value, then passes that promise as the next task's input. The next task receives a Promise object instead of the resolved value. Always await.try/catch — if you catch a failure and let the loop continue, later tasks run after a failure, which breaks the "stop on the first error" contract. Let the rejection propagate; don't catch it inside the loop.await on a non-promise resolves to that value on the next microtask, so the same line handles both; you don't need to sniff whether the return has a .then.reduce instead of a loop. The same waterfall is often written point-free as tasks.reduce((chain, task) => chain.then(task), Promise.resolve(initialValue)). It builds the exact .then chain the awaited loop runs; pick whichever reads clearer to you.AbortSignal and check signal.aborted at the top of each iteration, rejecting early to stop a long waterfall the caller no longer needs.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.