Critiq team
- rules
Rule spotlight: performance, N+1 awaits and unbounded concurrency
How Critiq flags sequential awaits in map flows and unbounded Promise fan-out, with real rule IDs, fixes, and CLI commands.
Rule spotlight: performance, N+1 awaits and unbounded concurrency content
Two performance smells show up in almost every async TypeScript service: you await work once per item in a collection, or you launch every downstream call at once with no limit. Under light traffic both patterns look fine. Under load they turn into latency cliffs, rate-limit storms, and connection pool exhaustion, often long after the PR that introduced them merged.
This rule spotlight walks through two stable catalog rules in @critiq/rules: ts.performance.no-n-plus-one-await-in-map and ts.performance.no-unbounded-concurrency. They target different failure modes, but they often appear in the same hydration or batch-import path. We also point at related rules (including polyglot unbounded-concurrency variants) and show how to inspect findings locally with critiq check and critiq rules explain.
Rule IDs in the OSS catalog
- ts.performance.no-n-plus-one-await-in-map, N+1 await patterns in map-like flows (TypeScript / JavaScript, high severity)
- ts.performance.no-unbounded-concurrency, unbounded Promise fan-out over unknown input (TypeScript / JavaScript, high severity)
- ts.performance.no-await-in-loop, sequential await inside loops (related; medium severity)
- go.performance.no-unbounded-concurrency, java.performance.no-unbounded-concurrency, py.performance.no-unbounded-concurrency, php.performance.no-unbounded-concurrency, ruby.performance.no-unbounded-concurrency, rust.performance.no-unbounded-concurrency, same fan-out class in other languages (experimental in Go; pattern-matched in polyglot adapters)
There is no rule id ts.performance.n-plus-one-await-map-fan-out in the catalog today. The shipped id is ts.performance.no-n-plus-one-await-in-map. Likewise, unbounded concurrency is ts.performance.no-unbounded-concurrency (with a no- prefix), not ts.performance.unbounded-concurrency.
N+1 awaits: the sequential map trap
The database world coined “N+1” for one query per row. In async TypeScript the same shape appears when you map over ids and await a fetch or ORM call inside the callback. Latency adds up linearly: ten items at 80ms each is 800ms wall time even when the API could have taken a batched or parallel path.
Critiq rule ts.performance.no-n-plus-one-await-in-map matches project-level facts emitted when source text looks like per-item awaits inside map-like flows, including await Promise.all(ids.map(async (id) => await fetch(...))) and for-loops that await inside the body. The rule metadata summarizes it as: per-item awaits inside map-like flows often create avoidable latency and fan-out bottlenecks.
Example: bad
export async function hydrateUserProfiles(ids: string[]) {
const profiles = [];
for (const id of ids) {
const res = await fetch(`/api/users/${id}`);
profiles.push(await res.json());
}
return profiles;
}
export async function hydrateViaMap(ids: string[]) {
return Promise.all(
ids.map(async (id) => await fetch(`/api/users/${id}`)),
);
}The for-loop version is the classic sequential N+1: simple to read, brutal at scale. The Promise.all + map variant still performs one await per id inside the mapper; Critiq still flags it under no-n-plus-one-await-in-map because the fan-out pattern matches the detector.
Why it hurts
- Wall-clock latency grows with collection size even when downstream services support batch APIs.
- You hold connections and memory longer than necessary on the caller.
- Retries and timeouts multiply: one slow item stalls the whole batch when work is strictly sequential.
- Observability looks like “mysterious slowness” because each request is individually fast.
Fix patterns
- Prefer a single batch endpoint (POST /users/bulk, GraphQL nodes, SQL WHERE id IN (...)) when the backend supports it.
- When you must fan out, use bounded concurrency (see below) instead of unbounded Promise.all over arbitrary arrays.
- If work is independent and small, parallelize with an explicit limit, not an unbounded map of async callbacks.
- For true sequential requirements, document why; consider ts.performance.no-await-in-loop as a separate signal when a plain for-loop awaits in hot paths.
export async function hydrateBatched(ids: string[]) {
const res = await fetch('/api/users/bulk', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ids }),
});
return res.json();
}Unbounded concurrency: the Promise.all(map) overload
Fixing N+1 by wrapping every per-item call in Promise.all feels like a win until item count is user-controlled. One large import can spawn hundreds of simultaneous TCP connections, saturate API rate limits, and trip circuit breakers downstream. The rule ts.performance.no-unbounded-concurrency exists for that second failure mode.
Catalog summary: unbounded Promise fan-out over unknown input can exhaust downstream capacity. Severity is high; category is performance.async. The TypeScript adapter flags Promise.all when the argument text includes .map( or common collection names (items, rows, records, users, list).
Example: bad
export async function enrichRows(items: string[]) {
return Promise.all(items.map((item) => fetch(item)));
}This is the canonical fixture shape in the rules catalog: Promise.all over items.map with no throttle. Item count comes straight from the caller, unbounded fan-out.
Example: better (bounded or sequential)
async function mapPool<T, U>(
items: T[],
worker: (item: T) => Promise<U>,
concurrency: number,
): Promise<U[]> {
const results: U[] = [];
let index = 0;
async function runWorker() {
while (index < items.length) {
const i = index++;
results[i] = await worker(items[i]);
}
}
await Promise.all(
Array.from({ length: Math.min(concurrency, items.length) }, runWorker),
);
return results;
}
export async function enrichRowsBounded(items: string[]) {
return mapPool(items, (item) => fetch(item), 8);
}A small pool caps in-flight work. Choose concurrency from downstream limits (rate limits, pool size, file descriptors), not from array.length.
Polyglot: same smell, other runtimes
If you ship Go, Java, Python, PHP, Ruby, or Rust alongside TypeScript, the catalog includes language-specific no-unbounded-concurrency rules. Polyglot adapters look for constructs such as Promise.all, CompletableFuture.allOf, asyncio.gather, Task.WhenAll, tokio::join!, and futures::future::join_all when the call site also suggests mapping over a collection (map, items, records, users, rows, list, iter).
# py.performance.no-unbounded-concurrency, flagged shape (catalog fixture)
import asyncio
def run(items):
return asyncio.gather(items.map(task))Stability and severity vary by language (Go rules are marked experimental in metadata). Treat them as hygiene signals: read the finding, confirm the call site is on a hot path, then add an explicit bound or batch API.
How the rules relate
no-n-plus-one-await-in-map and no-unbounded-concurrency are complementary. The first calls out map/loop shapes that serialize or repeat per-item awaits. The second calls out Promise.all fan-out that can spike parallelism with input size. A single refactor can trigger both: replacing a for-await loop with Promise.all(ids.map(...)) fixes sequential latency but may introduce unbounded concurrency. Good fixes batch when possible and cap parallelism when fan-out is required.
ts.performance.no-await-in-loop is a narrower sibling: it flags await inside loop bodies via AST facts (medium severity). Use it when you want a direct “sequential awaits in loops” signal without the broader map-flow heuristic.
Run critiq check locally
Install the OSS CLI and default catalog, then scan your repo. Findings include rule id, severity, and source range, the same ids referenced here.
npm install -D @critiq/cli @critiq/rules
npx critiq check .
npx critiq check --format json . | jq '.findings[] | select(.ruleId | test("performance"))'Commit .critiq/config.yaml with catalog.package: "@critiq/rules" and a preset (recommended or strict) so local runs and CI agree. In pull requests, use diff scope (--base / --head) to focus on changed files.
Inspect rules with critiq rules explain
Every finding ties to a rule file in the catalog. To read the human breakdown of what a rule matches and emits, without spelunking YAML, use explain on the rule path inside node_modules or a rules checkout:
npx critiq rules explain node_modules/@critiq/rules/catalog/rules/typescript/ts.performance.no-n-plus-one-await-in-map.rule.yaml
npx critiq rules explain node_modules/@critiq/rules/catalog/rules/typescript/ts.performance.no-unbounded-concurrency.rule.yamlAuthors can validate and test rules with critiq rules validate and critiq rules test when changing catalog fixtures. That loop is separate from check, which runs the full catalog against application code.
Where these patterns hide in real repos
The risky call sites are rarely in greenfield handlers. They cluster in data backfills, admin “re-sync” buttons, CSV imports, webhook replay tools, and test helpers that call production APIs. A queue consumer that loads 500 ids and hydrates each row with fetch is a textbook N+1. A migration script that uses Promise.all(rows.map(...)) against a third-party API is textbook unbounded concurrency. Both can pass unit tests with three fixtures and still fail the first time a customer uploads a large file.
Critiq analyzes project text and TypeScript AST facts, not your production metrics, so findings are about shape and policy, not measured p99 latency. That is intentional: you want the smell flagged at review time, when batching and pool sizes are cheap to design in, not after an incident postmortem.
Tuning and false positives
If a path is genuinely cold (one-off setup with a fixed tiny array), you can narrow noise with .critiq/config.yaml: disableRules lists stable rule ids, and severityOverrides can downgrade a rule for local experimentation. Prefer fixing hot paths over blanket disables, these rules exist because the failure mode scales with input size, which code review often underestimates.
apiVersion: critiq.dev/v1alpha1
kind: CritiqConfig
catalog:
package: "@critiq/rules"
preset: recommended
# Example: only if a documented cold path must stay as-is
# disableRules:
# - ts.performance.no-unbounded-concurrencyFor TypeScript monorepos, pair check with diff scope in CI so performance findings surface on the files the PR touched, alongside security and correctness categories. The critiq-action workflow runs the same catalog package; inline comments reference the same rule ids discussed here.
What to do in review
- When you see ts.performance.no-n-plus-one-await-in-map, ask for a batch API or a documented concurrency cap, not only Promise.all as a reflex.
- When you see ts.performance.no-unbounded-concurrency, trace who controls array length (request body, CSV import, queue depth).
- If both fire on the same function, treat it as a design smell: the path likely needs batching plus bounded parallel workers.
- Link reviewers to critiq rules explain output so the discussion stays tied to inspectable rule metadata, not taste.
Takeaway
Performance regressions in async code rarely arrive as a single slow function. They arrive as patterns, per-item awaits and unbounded fan-out, that scale with user data. Critiq names those patterns with stable rule ids so you can fix them before production traffic does. Run critiq check on the paths that hydrate, import, or sync collections; use critiq rules explain when you need the full rule contract; prefer batch APIs and explicit concurrency limits over map-shaped awaits with no guardrails.
More from the blog

- philosophy
The trust gap in AI-assisted coding, and what inspectable feedback looks like
Developers use AI assistants daily but often distrust review feedback. Inspectable rules, evidence, and local checks close that gap.
Read article
- philosophy
What evidence over vibes means in code review
Review comments should be defensible: tied to a rule, a line, severity, and references, not just confident prose.
Read article
- philosophy
Why we open-sourced the rules engine (and what stays in the catalog)
Critiq ships the rule engine, DSL, and 435+ OSS catalog rules in the open. Here is what you get locally, what Pro adds, and how to inspect rules yourself.
Read article