ESC

Type to search the knowledge base.

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.

intermediate7 min read
  • 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.
  • allSettled is the fail-soft fork. Series for plus await is 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:

  1. Map each present index to Promise.resolve().then(() => predicate(item, index, list)).
  2. await Promise.all on that list of flags. Fail fast if any predicate rejects.
  3. Walk the flags in index order. push the original item when the flag is truthy.
  4. 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

  1. arr.filter(async ...). Always keeps. The Promise is truthy. This is the whole interview until you name it.
  2. push inside Promise.all of async callbacks. Parallel start, completion order in the output. [slow, fast] becomes [fastItem, slowItem].
  3. Awaiting in filter’s callback. filter does not await. Same as (1).
  4. Breaking on the first false. That is find, and you skipped later items.
  5. Treating reject as false. Only do that if you said allSettled and documented it.
  6. Sparse write. out[i] = item for keepers leaves holes. Native filter never 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 async in Array.filter, because the Promise is truthy and filter will not wait. I start every predicate, wrap with Promise.resolve so throws become rejections, join with Promise.all so 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 to allSettled and say how rejected slots are treated. A for loop with await is series: slower, and later predicates never start after a throw.”

Further reading

Related guides