eslint-plugin-parallelize
v0.3.0
Published
ESLint rule that flags sequential awaits with no data dependency between them, and auto-fixes them into maximally parallel Promise.all groupings. Type-aware when type information is available.
Maintainers
Readme
eslint-plugin-parallelize
An ESLint rule that finds awaits that run sequentially but don't depend on each other, and auto-fixes them into maximally parallel Promise.all groupings.
// before
const user = await fetchUser();
const posts = await fetchPosts();
const enriched = await enrich(user, posts);
// after --fix
const [user, posts] = await Promise.all([fetchUser(), fetchPosts()]);
const enriched = await enrich(user, posts);The rule assumes your async operations are side-effect free with respect to each other — i.e. it is semantically safe to start them in any order. That assumption is what makes the rewrite sound; if your codebase relies on cross-call effect ordering, don't enable the auto-fix blindly.
Install
npm install --save-dev eslint-plugin-parallelizeUsage (flat config)
// eslint.config.js
import parallelize from 'eslint-plugin-parallelize';
export default [
{
plugins: { parallelize },
rules: {
'parallelize/no-sequential-await': 'warn',
},
},
];Or use the bundled config: parallelize.configs.recommended.
How it works
Statement runs
The rule scans each block for maximal runs of consecutive await statements in these forms:
await foo(); // expression
const x = await foo(); // declaration (any destructuring pattern)
x = await foo(); // assignment (incl. obj.prop = await foo())Within a run it builds a data-dependency graph using ESLint's scope analysis — identifier references are resolved to their actual variables, so shadowing, closures, destructuring defaults, and callback parameters are handled exactly. Statement B depends on statement A when any of:
- read-after-write — B reads a variable A writes (
const a = await f(); await g(a)) - write-after-read — B writes a variable A reads
- write-after-write — B writes a variable A writes
Property writes (obj.x = await f()) are conservatively treated as writes to obj. Untrackable targets (this.x, f().x) make the statement an ordering barrier.
Statements are then layered by longest path in the dependency graph. Any layer with two or more statements is available parallelism, and the layering is the fix:
// before: a ─→ b, a ─→ c, {b,c} ─→ d
const a = await f();
const b = await g(a);
const c = await h(a);
const d = await i(b, c);
// after --fix
const a = await f();
const [b, c] = await Promise.all([g(a), h(a)]);
const d = await i(b, c);Statements in the same layer may be reordered relative to dependent statements between them — this is safe exactly because the dependency analysis proved them independent.
Boolean logic
Short-circuit operators serialize awaits too:
const ok = await isAdmin(user) && await hasQuota(user);hasQuota doesn't start until isAdmin resolves. With side-effect-free operands the parallel form is equivalent (&&/||/?? become plain boolean combination once both values are in hand):
const [admin, quota] = await Promise.all([isAdmin(user), hasQuota(user)]);
const ok = admin && quota;The rule reports multi-await &&/||/?? trees (message only — the rewrite needs new bindings, so it isn't auto-fixed). Expressions where one operand's assignment feeds another ((x = await f()) && await g(x)) are left alone.
Type-aware mode (automatic)
The rule is fully functional syntactically on any JS/TS codebase. When it runs under @typescript-eslint/parser with type information enabled, it upgrades automatically — no separate rule, no option:
// eslint.config.js
import tseslint from 'typescript-eslint';
import parallelize from 'eslint-plugin-parallelize';
export default tseslint.config(
{ languageOptions: { parserOptions: { projectService: true } } },
parallelize.configs.recommended,
);With types available:
Un-awaited promise declarations stop being barriers.
const p = fetchB();between two awaits currently splits the run; with types the rule knowspis a thenable already in flight, analyzes it for dependencies like any other statement, and can group awaits across it:// before const a = await fetchA(); const p = fetchB(); const b = await fetchC(); // after --fix const p = fetchB(); const [a, b] = await Promise.all([fetchA(), fetchC()]);Awaits of sync-typed values no longer count as async work.
await parse(x); await parse(y);starts nothing asynchronous — with types it is not flagged (grouping would gain nothing).any/unknownkeep the syntactic behavior, so untyped code is never silently under-reported.
Loops
An await inside a loop serializes across iterations. Unlike core no-await-in-loop, this rule only reports when the serialization is unnecessary — when neither the await's inputs nor the decision to keep looping depend on results from previous iterations:
for (const item of items) {
await process(item); // reported: iterations are independent
}
let token;
do {
const page = await fetchPage(token); // not reported: loop-carried
token = page.next; // dependency via the paging token
} while (token);This works via a taint pass: variables written from await results are tainted, taint propagates through assignments to a fixpoint, and the loop is left alone when any of these hold:
- a reported await's inputs read tainted state (paging, folds, pointer chases)
- the loop condition reads tainted state or itself awaits (retry, polling)
- a
break/continue/return/throwis guarded by a tainted condition (poll-until-done) - the loop is
while (true)/for (;;)(daemons),for await ... of, or contains an untrackable property write (this.x = ...) - the loop only awaits already-in-flight promises
Loop findings are report-only (rewriting to map + Promise.all changes structure too much to auto-fix safely). If you enable this rule, disable core no-await-in-loop — this is a dependency-aware superset of the cases worth flagging.
When parallelizing wouldn't help
Awaiting an already in-flight promise sequentially costs nothing — the work is already running:
await p1; // p1 was started earlier
await p2; // fine: total time is max(p1, p2) either wayThe rule only reports when some await after the first in its group actually starts new work (contains a call, new, tagged template, or dynamic import()). So await p1; await foo(); is reported (foo could have started immediately), but await foo(); await p1; is not.
When the fix is withheld
The rule still reports, but won't auto-fix, when a faithful single-statement rewrite doesn't exist:
- assignments to existing bindings (
x = await f()) — would need to invent temporaries - mixed
const/letin one group - comments between the statements (they'd be destroyed)
Options
{
"parallelize/no-sequential-await": [
"warn",
{ "ignoreTry": false, "checkLoops": true, "requireConsumedResult": false }
]
}ignoreTry(defaultfalse) — skip blocks directly insidetry/catch/finally.Promise.allis fail-fast and starts every operation regardless of which one throws, which can matter for carefully staged error handling even without side effects.checkLoops(defaulttrue) — report loops whose awaits have no loop-carried dependency. Set tofalseif you prefer coreno-await-in-loop's blunter behavior (or no loop checking at all).requireConsumedResult(defaultfalse) — only flag when every awaited operation's result is consumed (assigned or destructured to a real binding). A run containing any statement-levelawait foo();— or a declaration/assignment that binds nothing, e.g.const {} = await foo()or an all-holesconst [,] = await foo()— is left alone; likewise a loop whose awaited result is discarded.A discarded (void) result is a strong signal the call is there for its side-effect (a mutation, cache invalidation, ordered delete, message send), which is exactly where hidden ordering/data dependencies that this analysis can't see tend to live —
await create(...); await refetch(...),await mutate(...); await invalidate(...), bottom-up deletes, and so on. Awaits whose results are read back are far more often genuine independent loads (data fetches,fetchPage, dynamicimport()) that parallelize safely.This is a conservative, opt-in heuristic: it can suppress a legitimately-parallelizable run whose reads happen to discard their results. Enable it when you want the rule biased hard toward safe suggestions on side-effect-heavy code.
Semantics changed by the fix
Even with side-effect-free operations, two observable differences exist:
- Failure timing/selection: sequentially, a rejection in the first await prevents later calls from ever starting. After the fix, all calls start;
Promise.allrejects with the first rejection to settle. (Promise.alldoes subscribe to every promise, so no unhandled-rejection warnings.) - Resource pressure: N concurrent calls instead of 1 at a time (connection pools, rate limits).
Both are almost always acceptable — and usually desirable — under the no-side-effects assumption, but it's why ignoreTry exists.
Limitations (v0.1)
- Statement-level only:
export const a = await f(), multi-declarator statements,return await f(), and statements containing nested awaits (await f(await g())) act as barriers rather than being analyzed. - No alias analysis:
a.x = await f(); b.y = await g()is considered independent whenaandbare distinct variables, even if they alias the same object. - No interprocedural analysis: a helper that internally awaits sequentially won't be seen.
- Without type information, promise detection is purely syntactic: un-awaited promise-typed declarations act as run barriers, and awaits of sync values are treated as async work. Enable
projectService(see type-aware mode) to lift both. - Ternaries (
await c() ? x : y) are out of scope for now. - Loop taint analysis is variable-level, not flow-sensitive (no SSA): a variable tainted anywhere in the loop is tainted everywhere, which errs toward silence.
Prior art
Nothing existing does dependency-aware detection of serializable awaits:
no-await-in-loop(ESLint core) — loops only, no dependency analysis (flags paging loops that are genuinely sequential; this rule doesn't).- eslint/eslint#17824 and typescript-eslint#8098 — the opposite concern (don't hold pending promises too long before awaiting).
eslint-plugin-promise— promise hygiene, not scheduling.
License
MIT
