Async Filter Function
Filter an array with an async predicate: wait for every predicate, keep input order, emit a dense array, and decide fail-fast vs allSettled.
- javascript
- async
- arrays
- interview
[1, 2, 3].filter(async (x) => x > 1) looks like an async filter. It is not. The callback returns a Promise, a Promise is an object, objects are truthy, and native filter keeps every slot. You get [1, 2, 3] on this turn, before any predicate settles. The async keyword did not make filter wait. It made every return value truthy.
The interview prompt is: given an array and an async predicate, return the items for which the predicate fulfills truthy. Wait for every predicate. Keep input order. Emit a dense array. If a predicate rejects, fail fast (or say you would switch to allSettled).
Do not await inside Array.prototype.filter. Do not push as promises finish. Do not stop launching later predicates just because an earlier one was false.
Key takeaways
- Start every predicate, join with
Promise.all, then compact.filter(async fn)never waits.- Output order matches the input. Completion order only decides when a flag lands, not where the item goes.
- The result is dense: kept values, no holes for the dropped ones. Same shape as native
filter.- First predicate rejection rejects the whole call. Later predicates already started; join is not cancel.
allSettledis the fail-soft fork. Seriesforplusawaitis slower and skips later starts on throw.
Keep order, wait for every predicate, dense output
Native filter is synchronous: skip holes (k in O), keep when the callback is truthy, push into a new array. An async filter has the same compacting rule. The extra work is that each predicate returns a thenable, so you cannot decide keep-or-drop until those thenables settle.
Two-phase procedure:
- Map each present index to
Promise.resolve().then(() => predicate(item, index, list)). await Promise.allon that list of flags. Fail fast if any predicate rejects.- Walk the flags in index order.
pushthe original item when the flag is truthy. - Return that array. Length is “how many kept,” not
items.length.
function asyncFilter(items, predicate) {
const flags = items.map((item, index) =>
Promise.resolve().then(() => predicate(item, index, items)),
);
return Promise.all(flags).then((keep) =>
items.filter((_, index) => keep[index]),
);
}
const delay =
(ms, value) =>
() =>
new Promise((resolve) => setTimeout(() => resolve(value), ms));
await asyncFilter(
[1, 2, 3, 4],
async (n) => {
await delay(n === 1 ? 40 : 5)();
return n % 2 === 0;
},
);
// [2, 4], after ~40ms (the slowest predicate), not ~55ms
items.map starts every predicate on this turn. Promise.all preserves flag order, so the later filter sees keep[0] for item 0 even if that predicate finished last. Wall clock tracks the slowest predicate, same idea as parallel execution.
Promise.resolve().then(() => predicate(...)) wraps thenables and turns a synchronous throw into a rejection, matching how a Promise.all polyfill treats mapper functions. If you call predicate raw and it throws before you attach .then, you never reach Promise.all.
items.filter((_, index) => keep[index]) densifies. Dropped items leave no holes. That is the native filter contract, not map. A version that writes out[index] = item for keepers would be sparse and the wrong length.
Empty input: Promise.all([]) fulfills [], then filter keeps []. Do not hang.
Why you must not skip later items
Filter is not find. A false at index 0 does not decide index 1. In the parallel version every predicate is already in flight, so there is nothing to skip on a falsy flag. The bug is writing a series loop that breaks, or that returns a partial array, when a predicate is false.
Falsy is not failure. 0, '', null, undefined, false, and NaN drop the item. A rejected promise is failure. Do not collapse those.
await asyncFilter([0, 1, 2], async (n) => n);
// [1, 2] : 0 is falsy, same as filter(Boolean)
Fail-fast versus allSettled
Default: Promise.all. The first predicate rejection rejects asyncFilter. You do not get the items whose predicates already fulfilled. Those predicates still run to completion unless you pass an AbortSignal into them.
const boom = asyncFilter([1, 2, 3], async (n) => {
if (n === 2) throw new Error('nope');
return true;
});
await boom; // rejects with Error('nope'); you do not get [1] or [1, 3]
Fail-soft: wait for every predicate with Promise.allSettled, then decide. Options you should name out loud:
After allSettled |
Result |
|---|---|
Any rejected slot |
Reject (or AggregateError), but every predicate finished |
Treat rejected as falsy |
Drop those items, keep fulfilled-and-truthy |
Return { kept, errors } |
Caller logs reasons; not a drop-in filter |
Say which contract you are implementing before you type. A dashboard that must hide one failing card and still render the rest wants settled flags. A permission check that cannot proceed on a thrown predicate wants all. The allSettled polyfill page is the status-bag shape; this page only needs the fork.
Series for plus await is a different product
async function asyncFilterSeries(items, predicate) {
const out = [];
for (let i = 0; i < items.length; i++) {
if (!(i in items)) continue;
const keep = await Promise.resolve().then(() =>
predicate(items[i], i, items),
);
if (keep) out.push(items[i]);
}
return out;
}
Same dense output, same truthiness, same fail-fast. Different schedule: predicate i + 1 starts only after predicate i fulfills. Wall clock is the sum. On rejection, later predicates never start. That is the series property from async series, and it is legal if the interviewer asked for it. It is not the default async filter. Do not sell a series loop as “the parallel one with await.”
for await...of over an array of already-started promises is sequential consumption, not sequential start, and not a filter. If you already have flags from Promise.all, compact them with a sync walk.
Holes, map, and native filter
Array.prototype.map skips holes (HasProperty). So does native filter. Building flags with items.map therefore does not call the predicate on a hole, and the trailing filter drops that hole too. Dense output, hole never kept, predicate never asked. That matches the filter polyfill.
A for from 0 to length - 1 would call predicate(undefined, i) on a hole. Say so if you write that loop. Present undefined (the value, not a hole) is a real item: filter(() => true) keeps it.
Footguns of an async filter
arr.filter(async ...). Always keeps. The Promise is truthy. This is the whole interview until you name it.pushinsidePromise.allofasynccallbacks. Parallel start, completion order in the output.[slow, fast]becomes[fastItem, slowItem].- Awaiting in
filter’s callback.filterdoes not await. Same as (1). - Breaking on the first
false. That isfind, and you skipped later items. - Treating reject as
false. Only do that if you saidallSettledand documented it. - Sparse write.
out[i] = itemfor keepers leaves holes. Nativefilternever does.
Truthy vs === true is the same rule as sync filter. Returning 1 keeps. Returning 0 drops. See truthy and falsy if you need the list.
Interview answer (out loud)
“I cannot put
asyncinArray.filter, because the Promise is truthy and filter will not wait. I start every predicate, wrap withPromise.resolveso throws become rejections, join withPromise.allso flags stay in input order, then compact the original items where the flag is truthy. The output is dense. A false flag does not skip later predicates. A rejected predicate fails the whole call; the others keep running unless I abort them. If they want every predicate to finish even on error, I switch toallSettledand say how rejected slots are treated. Aforloop withawaitis series: slower, and later predicates never start after a throw.”
Further reading
Array.prototype.filter: MDNPromise.all: MDNPromise.allSettled: MDNArray.prototype.filter: ECMA-262
Related on this site
- Array.filter polyfill
- Promise.all polyfill
- Promise.allSettled polyfill
- Run async tasks in parallel
- Run async tasks in series
- Truthy and falsy values
- Array map, filter, reduce
Related guides
- mapSeries AsyncMap one async iteratee at a time, preserve output order, fail fast on the first rejection, and contrast this with mapLimit worker pools.
- Analytics SDK Event QueueQueue analytics events, send them in series, retry the ones that fail, and flush on page hide without dropping the last click.
- Array.filter PolyfillWrite filter that skips holes, respects thisArg, and never writes empty slots for rejected items. Contrast with map’s hole-preserving result.
- Array.find PolyfillWrite Array.prototype.find: first callback hit wins, holes are read as undefined, nothing matching returns undefined, and findIndex is the index twin.
- Array.flat PolyfillImplement flat(depth) with Infinity, holes, and non-array array-likes. Recursion vs stack, and why concat(spread) blows up on huge inputs.