JavaScript Async filter Keeps Every Item: Await the Decisions
[1, 2, 3].filter(async value => value > 1) returns all three values. An async callback returns a promise, and filter() tests that promise’s truthiness immediately. It never waits for the boolean inside it. Adding await before the entire filter() call does not repair the selection.
For independent checks, await a mapped batch of decisions, then filter synchronously. Keep each decision attached to its original value so that completion order and later changes to the input array cannot mix them up.
Why even an immediately false result keeps the item
The ECMAScript filter algorithm converts each callback result to a boolean before deciding whether to copy the element. An async callback returns a promise even when its body contains no await. That object is truthy whether it eventually fulfills with false or true.
const wrong = await [1, 2, 3].filter(async () => false);console.log(JSON.stringify(wrong)); // [1,2,3]The outer await receives an array of original values, not a batch of callback promises. It cannot retrieve those discarded promises. If a callback rejects, this pattern also leaves its rejection outside the caller’s awaited result.
Pair values with decisions before selecting
This helper accepts an ordinary dense array and a predicate whose resolved result uses boolean truthiness. It takes a shallow snapshot before invoking any predicate. The callback receives the value and its snapshot index; it does not receive the original array or a custom this binding.
async function filterAsync(values, predicate) { const snapshot = [...values]; const rows = await Promise.all(snapshot.map(async (value, index) => ({ value, keep: await predicate(value, index), }))); return rows.filter(row => row.keep).map(row => row.value);}The filter checks row.keep, not row.value. Returning keep ? value : null and then applying filter(Boolean) would incorrectly discard retained 0, false, and empty strings. That is the separate falsy-value filtering trap.
Promise.all preserves input order in its successful result, regardless of fulfillment order. Here each row also carries the value captured before its asynchronous check. Filtering those rows avoids rereading a source array that another part of the program may have changed while the checks were pending.
Verify reverse completion and input mutation
Save the helper above followed by this fixture as async-filter.mjs, then run node async-filter.mjs. Both blocks together were executed during article preparation on macOS with Node.js 24.13.0, on October 16, 2026. No packages, timers, or network service are needed.
The gates deliberately finish checks in reverse order. Replacing every source element while the gates are closed tests whether selection returns the captured inputs or the replacements.
import assert from 'node:assert/strict';
const source = [0, false, ''];const releases = [];const completed = [];const batch = filterAsync(source, (value, index) => new Promise(resolve => { releases[index] = () => { completed.push(index); resolve(index !== 1); };}));assert.equal(releases.length, 3);source.splice(0, source.length, 'replacement');releases[2]();releases[1]();releases[0]();const kept = await batch;assert.deepEqual(completed, [2, 1, 0]);assert.deepEqual(kept, [0, '']);console.log(`completion indexes: ${completed.join(',')}`);console.log(`retained snapshot values: ${JSON.stringify(kept)}`);console.log(`current source: ${JSON.stringify(source)}`);
assert.deepEqual(await filterAsync([false], async () => true), [false]);assert.deepEqual(await filterAsync([1, 2], async () => false), []);let calls = 0;assert.deepEqual(await filterAsync([], async () => { calls++; }), []);assert.equal(calls, 0);await assert.rejects( filterAsync([1, 2], async value => { if (value === 2) throw new Error('lookup failed'); return true; }), /lookup failed/,);console.log('false values, all-false, empty input, and rejection: passed');Expected output:
completion indexes: 2,1,0retained snapshot values: [0,""]current source: ["replacement"]false values, all-false, empty input, and rejection: passedThis demonstrates association and selection for successful checks, plus propagation of a rejected predicate. It does not measure API throughput or prove anything about remote authorization checks.
Choose a failure and start policy
| Required behavior | Choice | Boundary |
|---|---|---|
| Independent checks may overlap; any failure invalidates the batch | The mapped filterAsync helper | No partial result is returned on rejection |
| Each check must finish before the next starts | An awaited loop | Later checks do not start after an uncaught failure |
| A failed check should count as a negative decision | Catch that specific expected error inside the predicate and return false | Unexpected failures should still be thrown |
The concurrent helper starts a predicate for every snapshot item without a concurrency cap. For a rate-limited endpoint, use a bounded worker policy or the serial form below. Choosing serial versus concurrent work is a separate decision from whether a value passes the filter.
async function filterAsyncSerial(values, predicate) { const snapshot = [...values]; const kept = []; for (let index = 0; index < snapshot.length; index++) { const value = snapshot[index]; if (await predicate(value, index)) kept.push(value); } return kept;}A rejected Promise.all does not cancel the other checks. If checks share a resource that needs cleanup, use the wait-before-cleanup pattern rather than closing that resource as soon as the aggregate rejects. A lookup error and a valid negative answer are different outcomes; silently converting every error to false can hide a failed batch.
Keep the snapshot boundary explicit
The snapshot protects array membership and value references. It does not deep-clone objects or freeze their fields. A concurrent mutation to record.enabled can still affect a predicate that reads that field later. Snapshot the needed fields separately if the application requires an immutable decision input.
These helpers deliberately require dense arrays. Native filter skips empty slots; spreading a sparse array produces undefined entries, which these helpers will check. They are application helpers, not complete replacements for native filter semantics on sparse arrays, subclasses, or custom iterables.
Supplemental assertions checked the serial helper’s value retention and stop-on-error behavior, shallow object references, and the sparse-input difference. Browser runtimes, cancellation, concurrency limits, and real network failures were not exercised. Keep the decision and original value together, then make the final selection only after the decisions have resolved.
References: ECMAScript: Array.prototype.filter, ECMAScript: PerformPromiseAll, MDN: filter, MDN: Promise.all. Sources checked October 16, 2026; execution boundary described above.