This is a practical, example-heavy walkthrough of everything Code Evolution Lab’s analysis engine can find and fix. It’s written for people who are new to static analysis, AST-based tooling, or this codebase specifically - if you’re a student, a new contributor, or you just want to understand what the product actually does under the hood, this is for you.

If you want the architectural overview (how the pieces fit together, the evolutionary algorithm, fitness scoring), read Detectors and Solution Generation first. This guide is the “here’s every single thing it catches and fixes, with real code” companion to those.

How to read this guide

The analysis engine has two halves for every performance pattern it knows about:

  1. A detector walks your code’s AST (Abstract Syntax Tree - basically, your code parsed into a tree structure the computer can reason about, instead of raw text) looking for a specific problematic pattern. When it finds one, it creates an Issue - a record of what’s wrong, where, how severe it is, and some metrics about it.
  2. A solution generator takes that Issue and produces one or more Solutions - actual rewritten code, ranked by a fitness score, explaining how to fix it.

Every section below follows the same shape:

One vocabulary note before we start: you’ll see the word ORM a lot. It stands for Object-Relational Mapper - a library like Prisma, Sequelize, or Mongoose that lets you write User.findAll() instead of raw SQL. Several detectors need to recognize which ORM you’re using so they can suggest a fix in the right dialect.


Table of contents

  1. N+1 Query Detector
  2. Missing Index Detector
  3. Missing Caching Detector
  4. Inefficient Loop Detector
  5. Memory Leak Detector
  6. Resource Leaks Detector
  7. Blocking I/O Detector
  8. Large Payload Detector
  9. DOM Manipulation Detector
  10. Bundle Size Detector
  11. ReDoS Detector
  12. Known gaps: what still falls through to the generic fallback
  13. Glossary

1. N+1 Query Detector

File: backend/src/detectors/n1-query-detector.ts Solution generator: backend/src/generators/n1-solution-generator.ts Issue types: 1 (n_plus_1_query)

What it’s looking for

Imagine you fetch a list of 100 orders, and then for each order, you make a separate database call to get its customer. That’s 1 query to get the orders, plus 100 more queries (one per order) - 101 queries total to do something that should take 1 or 2. This pattern is called “N+1” because you do 1 query to get N items, then N more queries, one per item.

The detector finds this by looking for loops (for, for...of, .forEach(), .map()) that contain a database call inside them, awaited on each iteration.

// BAD: one query to get orders, then one MORE query per order
async function getOrdersWithUsers() {
  const orders = await Order.findAll();
  for (const order of orders) {
    const user = await User.findByPk(order.userId); // <- runs once per order!
    order.user = user;
  }
  return orders;
}

Why it matters

This is one of the most common - and most expensive - mistakes in database-backed applications, because it’s invisible in development (where you might test with 3-5 rows) and devastating in production (where a table might have 100,000 rows). The project’s own benchmark studies found N+1 patterns run 10-100x slower than the equivalent single-query fix once the dataset reaches realistic size (~100K rows). Each extra query also means a round trip to the database, which adds real network latency on top of the query cost itself.

How severity is calculated

The detector counts how many separate database calls it finds inside the loop:

Query count in loop Severity
1 medium
2 high
3+ critical

The fix

There’s really only one issue type here, but the generator produces different code depending on which ORM it recognizes in your code - because the idiomatic fix looks completely different in Prisma versus Sequelize versus vanilla batching. This generator is transformation-based: it parses your actual code, figures out the ORM, the variable names, and the loop structure, and writes a fix using those exact names - not a generic placeholder.

If Prisma is detected (fitness 95, the highest-ranked option when applicable):

// GOOD: one query total, using Prisma's `include` for eager loading
const orderWithRelations = await prisma.order.findMany({
  include: {
    user: true, // Eager load user
  }
});

If Sequelize is detected (fitness 93):

// GOOD: one query total, using Sequelize's `include`
const orderWithRelations = await Order.findAll({
  include: [
    { model: User, as: 'users' },
  ]
});

Generic fallback: batch the query before the loop (fitness 92, works regardless of ORM):

// GOOD: collect the IDs first, then one query for all of them
const orders = await Order.findAll();

const allIds = orders.map(order => order.userId);
const allUsers = await User.findAll({ where: { id: allIds } });
const userMap = new Map(allUsers.map(u => [u.id, u]));

for (const order of orders) {
  order.user = userMap.get(order.userId); // no query here anymore
}

Angular reactive forms variant - if the “N+1” pattern is actually many form.get('field')?.value calls instead of database queries (a similar N-reads-instead-of-1 problem), the generator offers a batch-form-reads strategy (fitness 88) that replaces them with a single form.getRawValue() call.

Memoization, for cases where the repeated call is expensive but not a database query (fitness 82) - wraps the repeated call in a Map-based cache keyed by input.


2. Missing Index Detector

File: backend/src/detectors/missing-index-detector.ts Solution generator: backend/src/generators/missing-index-solution-generator.ts Issue types: 3 (missing_database_index, raw_query_index_hint, multiple_where_conditions)

What it’s looking for

A database index is like the index at the back of a textbook - instead of scanning every single page (row) to find what you’re looking for, the database can jump straight to it. Without an index on a column you filter by, the database has to check every single row in the table (a “full table scan”) to find matches.

This detector looks at where clauses in ORM queries (and raw SQL) and flags columns that are being filtered on without an obvious index behind them.

// BAD: filtering on `status` and `role` - if neither has an index,
// this scans every row in the users table
const activeAdmins = await prisma.user.findMany({
  where: { status: 'active', role: 'admin' }
});

Why it matters

The cost of a missing index grows with your table size, which is exactly why it often doesn’t show up until production. A full table scan is O(n) - it checks every row - while an indexed lookup is closer to O(log n) thanks to how database indexes are structured (typically a B-tree). The project’s benchmark studies measured 10-100x slower query times once a table reaches ~100K rows without the right index. Multiple unindexed where conditions compound this further, and can also cause full table scans even when a single-column index does exist, if the query filters on a combination of columns that index doesn’t cover.

The three issue types

Issue type What triggers it
missing_database_index An ORM query (findMany, findAll, find, etc.) filters on a field with no apparent index
raw_query_index_hint A raw SQL string has a WHERE clause on an unindexed column
multiple_where_conditions A query filters on 2+ columns together, which usually needs a composite index (an index covering multiple columns together, not just each column separately)

The fix

This generator is transformation-based - it figures out which ORM you’re using and which fields are involved, then generates the actual index-creation code in the right syntax for your stack. It offers up to 6 different strategies depending on what it can detect, since “add an index” looks completely different across ORMs:

Prisma schema index:

model User {
  // ... your existing fields
  @@index([status, role])
}

Sequelize migration:

await queryInterface.addIndex('users', ['status', 'role'], {
  name: 'idx_users_status_role',
});

TypeORM decorator:

@Index(['status', 'role'])
@Entity()
export class User {
  // ...
}

Mongoose schema index:

userSchema.index({ status: 1, role: 1 });

Raw SQL:

CREATE INDEX idx_users_status_role ON users (status, role);

Composite index consolidation - if it detects you already have several separate single-column indexes that always get queried together, it suggests replacing them with one composite index instead, since a composite index on (status, role) can serve a query filtering on just status too, but not the reverse.


3. Missing Caching Detector

File: backend/src/detectors/missing-caching-detector.ts Solution generator: backend/src/generators/missing-caching-solution-generator.ts Issue types: 4 (repeated_expensive_call, missing_memoization, api_without_cache, pure_function_no_memo)

What it’s looking for

This detector catches four different flavors of “you’re redoing expensive work you already did” - and the fix for each is genuinely different, which is why they’re separate issue types instead of one generic “add caching” message.

Issue type 1: repeated_expensive_call

The exact same call, with the exact same arguments, made more than once in the same scope.

// BAD: axios.get('/api/config') called twice with identical arguments
const a = await axios.get('/api/config');
doSomething(a);
const b = await axios.get('/api/config'); // <- same call again!
doSomethingElse(b);

Why it matters: this isn’t really “caching” in the traditional sense - it’s just redundant work in the same execution. There’s no reason to make the network round-trip twice.

The fix (extract-to-variable, fitness 90):

// GOOD: compute once, reuse the result
const cachedGetResult = await axios.get('/api/config');
doSomething(cachedGetResult);
doSomethingElse(cachedGetResult);

Issue type 2: missing_memoization

An expensive computation (.sort(), .reduce(), .filter(), JSON.parse/stringify) that re-runs every time a function or component re-executes, even when its inputs haven’t changed.

// BAD: re-sorts on every render, even if `items` hasn't changed
function ProductList({ items }) {
  const sorted = items.sort((a, b) => a.price - b.price);
  return <div>{sorted.length} products</div>;
}

The fix depends on whether the file looks like React (detected by checking for react imports or hook usage in the file). If it does, you get a react-usememo strategy (fitness 90) in addition to a framework-agnostic one:

// GOOD (React): only recomputes when `items` actually changes
const result = useMemo(() => (
  items.sort((a, b) => a.price - b.price)
), [items]);
// GOOD (framework-agnostic, fitness 82): Map-based cache
const _memoCache = new Map();
function getMemoized(key, compute) {
  if (!_memoCache.has(key)) _memoCache.set(key, compute());
  return _memoCache.get(key);
}
const result = getMemoized(items, () => items.sort((a, b) => a.price - b.price));

Issue type 3: api_without_cache

A database query or external API call in a hot path (something called often) that has no caching layer in front of it at all.

// BAD: hits the database on every single call, even for data
// that barely changes
async function getOrders() {
  return await prisma.order.findMany({ where: { status: 'pending' } });
}

The fix offers two variants: an in-memory TTL (time-to-live) cache for a single-process app, and a Redis-backed one for anything running across multiple servers/containers:

// GOOD: in-memory cache with a TTL (fitness 85)
const _cache = new Map();
const CACHE_TTL_MS = 60_000; // adjust based on how fresh this data needs to be

async function getCached(key) {
  const hit = _cache.get(key);
  if (hit && Date.now() - hit.timestamp < CACHE_TTL_MS) {
    return hit.data;
  }
  const data = await prisma.order.findMany({ where: { status: 'pending' } });
  _cache.set(key, { data, timestamp: Date.now() });
  return data;
}

The Redis variant (fitness 80) follows the same shape but calls out to a shared Redis instance instead of a local Map, so the cache is shared across every server/container running your app - important if you’re running more than one instance, since an in-memory cache is per-process and won’t be consistent across instances.

Issue type 4: pure_function_no_memo

A pure function (same input always produces the same output, no side effects) that gets called repeatedly without any caching wrapper.

The fix (function-memoize-wrapper, fitness 80):

// GOOD: wraps the function so repeated calls with the same
// arguments are served from a cache
function memoize(fn) {
  const cache = new Map();
  return (...args) => {
    const key = JSON.stringify(args);
    if (!cache.has(key)) cache.set(key, fn(...args));
    return cache.get(key);
  };
}

const computeTotalMemoized = memoize(computeTotal);

4. Inefficient Loop Detector

File: backend/src/detectors/inefficient-loop-detector.ts Solution generator: backend/src/generators/inefficient-loop-solution-generator.ts Issue types: 12 - the largest detector in the project

What it’s looking for

Loops execute their body over and over, so even a small inefficiency inside one gets multiplied by however many items you’re iterating over. This detector catches 12 distinct anti-patterns that show up inside loops. We’ll go through all 12.

Why it matters (the general case)

A pattern that costs an extra 1ms is invisible when a loop runs 5 times in a test. Run that same loop over 10,000 real records and that 1ms becomes 10 seconds. The project’s own benchmark studies found nested loops run 64x slower at n=10,000 compared to an optimized alternative, and JSON operations inside a loop run 46x slower at n=100,000. Loops are where inefficiencies compound the fastest.

await_in_loop

Using await inside a loop runs each iteration one at a time, waiting for each to finish before starting the next - even when the operations don’t depend on each other at all.

// BAD: each item waits for the previous one to finish first
async function processItems(items) {
  const results = [];
  for (const item of items) {
    const result = await processItem(item); // <- sequential!
    results.push(result);
  }
  return results;
}

The fix (promise-all, fitness 92): run everything at once instead of one-by-one.

// GOOD: all items processed concurrently
await Promise.all(items.map(async (item) => {
  const result = await processItem(item);
  return result;
}));

A second option, batch-async (fitness 88), processes items in fixed-size batches instead of all at once - useful when running everything simultaneously would overwhelm an API rate limit or a connection pool:

const BATCH_SIZE = 5;
const results = [];
for (let i = 0; i < items.length; i += BATCH_SIZE) {
  const batch = items.slice(i, i + BATCH_SIZE);
  const batchResults = await Promise.all(batch.map(async (item) => {
    return processItem(item);
  }));
  results.push(...batchResults);
}

nested_loops

A loop inside another loop (or three deep, or more) creates O(n²) complexity (or worse) - meaning the work grows quadratically, not linearly, as your data grows.

// BAD: for every order, scan through every single product to find a match.
// 1,000 orders x 1,000 products = 1,000,000 comparisons
for (const order of orders) {
  for (const product of products) {
    if (order.productId === product.id) {
      order.product = product;
    }
  }
}

The fix (flatten-loops, fitness 85): build a lookup Map once, then look things up in O(1) instead of scanning.

// GOOD: one pass to build the map, one pass to look things up.
// 1,000 + 1,000 = 2,000 operations instead of 1,000,000
const productsMap = new Map();
for (const product of products) {
  const key = product.id;
  if (!productsMap.has(key)) productsMap.set(key, []);
  productsMap.get(key).push(product);
}
for (const order of orders) {
  const matched = productsMap.get(order.productId) || [];
  // process matched items in O(1) lookup
}

array_lookup_in_loop

Calling .includes(), .indexOf(), .find(), or similar array-search methods inside a loop. Each of those searches is itself O(n) (it may have to check every element), so doing it inside another loop creates the same O(n²) problem as nested loops - just less visually obvious.

// BAD: .includes() scans the whole array, on every iteration
for (const user of users) {
  if (bannedIds.includes(user.id)) { // O(n) search, n times = O(n²)
    flagUser(user);
  }
}

The fix: a Set (for simple membership checks) offers O(1) lookups instead of a linear scan.

// GOOD: Set.has() is O(1) - constant time regardless of size
const bannedIdsSet = new Set(bannedIds);
// Use: bannedIdsSet.has(value) instead of bannedIds.includes(value)

For .find()-style lookups by key, the generator offers a Map-based version instead, since a Map gives you O(1) lookup by key the way .find() gives you a matching object.

json_operations_in_loop

JSON.parse() and JSON.stringify() aren’t free - they walk the entire object/string every time. Calling them repeatedly inside a loop, especially on large objects, adds up fast.

// BAD: serializing the same shape of data on every iteration
for (const item of items) {
  const snapshot = JSON.parse(JSON.stringify(item)); // deep clone, every time
}

The fix (json-cache, fitness 86): cache results by a stable key so identical items aren’t re-processed.

const jsonCache = new Map();
for (const item of items) {
  const key = item.id || JSON.stringify(item);
  if (!jsonCache.has(key)) {
    jsonCache.set(key, JSON.parse(JSON.stringify(item)));
  }
  const cached = jsonCache.get(key);
}

sync_file_io_in_loop

Synchronous file operations (readFileSync, writeFileSync, etc.) inside a loop - each call blocks Node’s event loop entirely until it completes, and it does that once per iteration. (See the Blocking I/O Detector section below for why blocking the event loop is such a big deal.)

// BAD: blocks the entire server, once per file
for (const file of files) {
  const data = fs.readFileSync(file, 'utf8');
}

The fix (async-file-io, fitness 90): switch to the promise-based fs API and run them concurrently.

// GOOD: all files read in parallel, without blocking anything
const fs = require('fs').promises;
const results = await Promise.all(files.map(file => fs.readFile(file, 'utf8')));

regex_compilation_in_loop

Creating a regular expression (new RegExp(...) or a /pattern/ literal used freshly each time) inside a loop recompiles the pattern on every single iteration, when the pattern itself never changes.

// BAD: the regex is identical every time, but gets recompiled anyway
for (const line of lines) {
  const match = line.match(/^\d{3}-\d{4}$/);
}

The fix (regex-hoist, fitness 88): move it outside the loop so it’s compiled exactly once.

// GOOD: compiled once, reused every iteration
const compiledRegex = /^\d{3}-\d{4}$/;
for (const line of lines) {
  compiledRegex.lastIndex = 0;
  const match = compiledRegex.test(line);
}

inefficient_array_chaining

Chaining .filter().map() walks the array twice - once for the filter, once for the map - when a single pass could do both.

// BAD: two full passes over the array
const result = items.filter(x => x.active).map(x => x.name);

The fix (chained-to-single-pass, fitness 90): combine both steps into one .reduce() pass.

// GOOD: one pass instead of two
const result = items.reduce((acc, item) => {
  if (item.active) acc.push(item.name);
  return acc;
}, []);

nested_array_methods

Similar to nested loops, but with array methods: a .map()/.filter()/.forEach() whose callback contains another array method call on data related to the outer item - same O(n²) complexity, different syntax.

// BAD: for every order, .find() scans the whole users array
const withUsers = orders.map(order =>
  users.find(u => u.id === order.userId)
);

The generator offers the same chained-to-single-pass strategy as above, plus a nested-foreach-to-forof strategy (fitness 85) that converts the nested method calls into explicit for...of loops - which doesn’t fix the complexity by itself, but makes it much easier to then add a Map-based lookup, since the loop body is now plain, editable code instead of buried inside callback chains.

string_concat_in_loop

Using += to build up a string inside a loop. Strings in JavaScript are immutable, so every += actually creates a brand new string and copies the old content into it - meaning a loop that builds a string over 1,000 iterations creates roughly 1,000 intermediate string objects along the way.

// BAD: creates a new string object on every iteration
let html = '';
for (const item of items) {
  html += `<li>${item.name}</li>`;
}

The fix (array-join, fitness 87): push pieces into an array, then join once at the end - only one final string gets created.

// GOOD: only one string is actually created, at the very end
const htmlParts = [];
for (const item of items) {
  htmlParts.push(`<li>${item.name}</li>`);
}
const html = htmlParts.join('');

dom_manipulation_in_loop

Touching the DOM (appendChild, innerHTML, insertBefore, removeChild) inside a loop. Every DOM write can trigger the browser to recalculate layout (“reflow”) - doing that once per loop iteration instead of once total is expensive. (This overlaps conceptually with the DOM Manipulation Detector below, which catches DOM issues outside of loops too - this specific issue type is shared between both detectors, but its fix always comes from this generator.)

// BAD: potentially hundreds of reflows, one per item
for (const item of items) {
  const el = document.createElement('div');
  el.textContent = item.name;
  container.appendChild(el); // <- triggers layout recalculation
}

The fix (batch-dom, fitness 88): build everything off-screen in a DocumentFragment, then attach it to the real DOM once.

// GOOD: one single DOM write at the end, not one per item
const fragment = document.createDocumentFragment();
for (const item of items) {
  const el = document.createElement('div');
  el.textContent = item.name;
  fragment.appendChild(el); // this doesn't touch the live DOM at all
}
container.appendChild(fragment); // only ONE real reflow happens here

array_push_in_loop and object_keys_with_lookup

The detector also flags two more patterns: array_push_in_loop (using array.push() inside a loop instead of .map() or pre-sizing the array - a minor, low-severity inefficiency) and object_keys_with_lookup (Object.keys(obj).includes(x) or similar - the same O(n²) problem as array_lookup_in_loop, just reached via Object.keys() instead of an array directly).

// BAD: array_push_in_loop
const results = [];
for (const item of items) {
  results.push(item.value);
}

// BAD: object_keys_with_lookup - O(n) scan on every iteration
for (const key of otherKeys) {
  if (Object.keys(config).includes(key)) {
    // ...
  }
}

The fixes: array_push_in_loop converts the loop to a single .map() call when the push is unconditional, or a .reduce() when it’s wrapped in an if:

// GOOD
const results = items.map(item => item.value);

object_keys_with_lookup replaces the Object.keys(...).includes(...) scan with a direct in check - O(1) instead of building the full keys array and scanning it:

// GOOD
const hasKey = key in config;

5. Memory Leak Detector

File: backend/src/detectors/memory-leak-detector.ts Solution generator: backend/src/generators/memory-leak-solution-generator.ts Issue types: 4 (event_listener_leak, timer_leak, global_variable_leak, closure_memory_leak)

What it’s looking for

A memory leak is memory your program allocated but will never release, because nothing is left that will ever clean it up. Unlike most bugs, leaks don’t crash anything immediately - they slowly grow memory usage over the life of a running process, until eventually performance degrades or the process crashes from running out of memory. This is especially dangerous in long-running servers and in single-page apps where a user might keep a tab open for hours.

This detector is framework-aware: it checks your imports and code structure to figure out if you’re in React, Vue, or Angular, because the correct place to put cleanup code is completely different in each one.

event_listener_leak

Calling addEventListener without ever calling the matching removeEventListener.

// BAD (React): every time this effect runs, a new listener is added,
// and it's NEVER removed - even when the component unmounts
useEffect(() => {
  window.addEventListener('resize', handleResize);
}, []);

The fix is framework-specific. In React, add a cleanup function:

// GOOD: cleanup function runs automatically when the component unmounts
useEffect(() => {
  const handleResize = () => { /* ... */ };
  window.addEventListener('resize', handleResize);
  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);

In Angular, the fix goes in ngOnDestroy; in Vue, in beforeUnmount/unmounted. The generator also offers a modern, framework-agnostic option using AbortController, which lets you cancel many listeners at once without tracking each one individually:

// GOOD: one abort() call removes every listener registered with this signal
const controller = new AbortController();
element.addEventListener('click', handleClick, { signal: controller.signal });
// later:
controller.abort();

timer_leak

setInterval or setTimeout without a corresponding clearInterval/clearTimeout. This is treated as more severe when it’s a setInterval (severity can reach critical), because an interval keeps firing forever until explicitly cleared - not just once, like a timeout.

// BAD: this interval keeps running even after the component
// that created it is gone
useEffect(() => {
  const intervalId = setInterval(() => {
    fetchData();
  }, 5000);
}, []);

The fix, same framework-aware pattern as event listeners:

// GOOD
useEffect(() => {
  const intervalId = setInterval(() => {
    fetchData();
  }, 5000);
  return () => {
    clearInterval(intervalId);
  };
}, []);

For non-framework code, the generator offers a class-based pattern with an explicit destroy() method, or a TimerManager utility that tracks multiple timers and clears them all with one clearAll() call - useful when a single component or module creates several timers.

global_variable_leak

Assigning to window.something or global.something. This isn’t a “leak” in the strict technical sense the way an uncleaned timer is, but it does two things that both smell like leaks: the data lives for the entire lifetime of the page/process (it’s never eligible for garbage collection unless explicitly nulled out), and it pollutes the shared global namespace where any other code could accidentally read or overwrite it.

// BAD: lives forever, and any other script can read or clobber it
window.myAppData = fetchData();

The fix (module_scope, fitness 95): use module-scoped variables with explicit getter/setter/cleanup functions instead.

// GOOD: scoped to this module, not globally visible
let myData = null;

export function initializeData() {
  myData = fetchData();
}
export function getData() {
  return myData;
}
export function cleanup() {
  myData = null;
}

Other options include a singleton class with an explicit reset() method, or (for frontend apps) React’s Context/Provider pattern, which ties the data’s lifetime to a component tree instead of the global object.

closure_memory_leak

A closure (a function that “remembers” variables from where it was created) that captures a large data structure from its surrounding scope, keeping that entire structure alive in memory for as long as the closure itself exists - even if the closure only actually needs a tiny piece of it.

// BAD: `process` holds onto the ENTIRE 100MB largeData array,
// forever, even if it only ever needs one item from it
const largeData = fetchLargeDataset(); // 100MB
function createProcessor() {
  return function process(id) {
    return largeData.find(item => item.id === id);
  };
}

The fix depends on what you actually need. If you only need a small piece, extract just that piece before creating the closure (fitness 95, the simplest and safest fix):

// GOOD: closure only holds onto `name`, not the whole 100MB object
function createHandler(largeObject) {
  const name = largeObject.metadata.name; // extract only what's needed
  return function handle() {
    console.log(name);
  };
}

If you genuinely need access to the large data but want it to be garbage-collectible when nothing else references it, the generator offers a WeakMap-based strategy instead - a WeakMap doesn’t prevent its keys from being garbage collected the way a regular Map or closure would.


6. Resource Leaks Detector

File: backend/src/detectors/resource-leaks-detector.ts Solution generator: backend/src/generators/resource-leaks-solution-generator.ts Issue types: 4 (unclosed_connection, unclosed_stream, unclosed_file_handle, resource_without_cleanup)

What it’s looking for

This is a close cousin of the Memory Leak Detector, but for system resources rather than JavaScript memory: database connections, file handles, streams, and other things your operating system hands out in limited quantities. A database connection pool might allow 10-100 concurrent connections; a Linux process typically can’t have more than ~1024 open file descriptors at once. Leak enough of these and your app doesn’t just get slow - it starts throwing “connection timeout” or “too many open files” errors and can crash outright.

unclosed_connection

A database connection or similar resource created without ever calling .close(), .end(), .release(), or the equivalent.

// BAD: this connection is never closed
const conn = db.createConnection(config);
// ... use conn ...
// (no conn.close() anywhere)

The fix (try-finally-close, fitness 90): guarantee cleanup runs, even if something in between throws an error.

// GOOD: finally block runs no matter what happens inside try
const connection = await db.createConnection(config);
try {
  // ... use the connection ...
} finally {
  await connection.close();
}

A second option uses using (JavaScript’s newer explicit resource management syntax, available in Node 24+ and TypeScript 5.2+), which calls the resource’s cleanup method automatically when it goes out of scope - similar in spirit to Python’s with statement:

// GOOD (modern syntax): cleanup happens automatically at scope exit
await using connection = await db.createConnection(config);

unclosed_stream

A stream (like fs.createReadStream) created without error handling or a way to stop consuming it early.

// BAD: no error handler - an error here could crash the process,
// and there's no way to stop reading early
const stream = fs.createReadStream(path);

The fix offers a manual approach (add an 'error' listener and call .destroy()):

// GOOD
const stream = fs.createReadStream(path);
stream.on('error', (err) => {
  console.error('Stream error:', err);
  stream.destroy();
});

…or the modern stream/promises pipeline() function, which handles error propagation and cleanup for every stream in a chain automatically (fitness 88, generally the better option when you’re piping one stream into another):

// GOOD: pipeline() destroys every stream in the chain on error, automatically
import { pipeline } from 'stream/promises';
await pipeline(
  fs.createReadStream(path),
  destinationStream
);

unclosed_file_handle

Specifically about fs.promises.open() (or fs.open()) results that are never .close()’d.

The fix is the same try/finally pattern as connections, plus a Node-specific trick: Node’s FileHandle object (what fs.promises.open() returns) has supported automatic cleanup via await using natively since Node 20, without needing any extra wrapper code:

// GOOD (Node 20+): no explicit .close() needed at all
await using handle = await fs.promises.open(path, 'r');

resource_without_cleanup

A broader catch-all for things like WebSocket, EventSource, or Worker instances that get created but never get their cleanup method called (.close(), .terminate(), etc. - the detector tracks which method applies to which resource type).

// BAD: this WebSocket connection is never closed
const ws = new WebSocket(url);

The fix always includes a straightforward explicit cleanup call:

// GOOD
const ws = new WebSocket(url);
// call this when you're done with `ws`:
ws.close();

…and if the file looks like a React, Angular, or Vue component, it also offers a framework-lifecycle version, seeded with the real resource type and variable name rather than a placeholder:

// GOOD (React)
useEffect(() => {
  const ws = new WebSocket(url);
  return () => {
    ws.close();
  };
}, []);

7. Blocking I/O Detector

File: backend/src/detectors/blocking-io-detector.ts Solution generator: backend/src/generators/blocking-io-solution-generator.ts Issue types: 6 (sync_file_operation, sync_crypto_operation, sync_child_process, unawaited_network_call, sync_database_operation, sync_fs_import)

What it’s looking for

Node.js runs your JavaScript on a single thread. This is fine for most work, because I/O operations (reading files, network calls, database queries) are normally handled asynchronously - Node hands the work off and keeps processing other requests while it waits. But synchronous versions of these operations (anything ending in Sync, like readFileSync) block that single thread completely until they finish. While one is running, your server can’t do anything else - not handle other requests, not respond to health checks, nothing.

Why it matters

This is one of the most severe categories in the whole tool, because the damage isn’t proportional to how “big” the blocking call looks in your code - it’s proportional to how many requests are waiting behind it. The project’s benchmarks found blocking I/O runs 5-15x slower in terms of overall throughput under load, because a single 100ms blocking call doesn’t just cost 100ms once - under load, every other request queues up behind it too.

sync_file_operation

Any of readFileSync, writeFileSync, appendFileSync, existsSync, statSync, etc.

// BAD: blocks the entire server for as long as this file read takes
const config = fs.readFileSync('./config.json', 'utf8');

The fix (fs-promises-await, fitness 92): swap to the promise-based version. The detector already computes the exact async method name for you (just strips Sync off the end), so this fix is largely mechanical.

// GOOD: doesn't block anything while it waits
const result = await fs.promises.readFile('./config.json', 'utf8');

A callback-based fallback (fitness 78) is also offered, for the rare case where the surrounding code can’t be made async.

sync_crypto_operation

This one has an interesting split. Methods like pbkdf2Sync and scryptSync genuinely have async equivalents and get a real fix (promisify-crypto, fitness 85):

// GOOD
const { promisify } = require('util');
const pbkdf2Async = promisify(crypto.pbkdf2);
const result = await pbkdf2Async(password, salt, 1000, 64, 'sha512');

But createHash and createHmac have no async equivalent in Node’s crypto API at all - they’re synchronous by design, because creating a hash object is cheap. Rather than fabricate a fake “async” version that doesn’t actually exist, the generator gives you an honest note instead (fitness 65, intentionally lower - this is informational, not really a “fix”):

// NOTE: createHash() has no async equivalent - hashing is fast and
// synchronous by design in Node, so this usually isn't your real
// bottleneck. If profiling proves otherwise, move the work to a
// worker thread instead of faking an async version:
const { Worker } = require('worker_threads');
// hash-worker.js would call createHash(...) and postMessage the digest

sync_child_process

execSync, execFileSync, spawnSync - these block until the external process finishes running entirely.

// BAD: your server can't do anything else while `ls` runs
const output = execSync('ls -la');

The fix (promisify-child-process, fitness 88):

// GOOD
const { promisify } = require('util');
const exec = require('child_process').exec;
const execAsync = promisify(exec);
const { stdout, stderr } = await execAsync('ls -la');

unawaited_network_call

Not actually about synchronous code - this one catches an async call (like axios.get(url)) that’s made but never awaited. That means your code moves on immediately without waiting for the response, silently dropping any errors and potentially racing ahead of data it actually depends on.

// BAD: this fires off the request, but nothing waits for it to finish
axios.get('/api/log-event');

The fix is the simplest one in the whole catalog - just add the missing await:

// GOOD
const result = await axios.get('/api/log-event');

sync_database_operation

A synchronous-named database call (e.g. db.querySync(sql)) - blocks the event loop and every other in-flight request while it runs.

The fix, same shape as the fs one:

// GOOD
const result = await db.query(sql);

sync_fs_import

This one looks at your imports, not a call site - import { readFileSync, writeFileSync } from 'fs' suggests the file is going to use blocking patterns even before you see where.

The fix: rewrite the import to pull from fs/promises (which exports the async names directly) instead:

// GOOD
import { readFile, writeFile } from 'fs/promises';
// update call sites: readFileSync -> readFile, writeFileSync -> writeFile, and add 'await'

8. Large Payload Detector

File: backend/src/detectors/large-payload-detector.ts Solution generator: backend/src/generators/large-payload-solution-generator.ts Issue types: 3 (large_api_payload, select_all_query, large_return_payload)

What it’s looking for

This detector catches endpoints and queries that return more data than they should - unbounded result sets, every column instead of the ones you actually need, and API responses with no pagination. It does real data-flow analysis: it traces variables from a database query all the way to where they get sent in a response (res.json(...)), so it can tell the difference between “this query result gets returned to the client unpaginated” and “this query result is just used internally and never leaves the server.”

Why it matters

A 20MB JSON response isn’t just slow to send - it’s slow to parse on the receiving end too (JSON parsing is CPU-bound), it consumes memory on both ends, and on a mobile connection it can take tens of seconds. It also usually means the database did more work than necessary fetching and serializing all those rows and columns in the first place.

select_all_query

A query with no field selection (select/attributes) and no row limit.

// BAD: every column, every row, no limit
const users = await User.findAll();

The fix stacks up incrementally - specify fields first (fitness 95, the simplest single change):

// GOOD: only fetch what you actually need
const users = await User.findAll({
  attributes: ['id', 'name', 'email', 'createdAt']
});

…then add a limit:

const users = await User.findAll({
  attributes: ['id', 'name', 'email'],
  limit: 100,
  offset: 0,
});

For Mongoose specifically, there’s also a .lean() option, which returns plain JavaScript objects instead of full Mongoose documents - faster and lighter, if you don’t need the document’s built-in methods.

large_api_payload

The data-flow-aware version: a query result that’s missing field selection or pagination and actually flows into an API response.

// BAD: an unbounded query result, sent straight to the client
app.get('/api/users', async (req, res) => {
  const users = await User.findAll();
  res.json(users);
});

The fix offers several pagination strategies. Limit/offset (fitness 95, the most familiar to most developers):

// GOOD
app.get('/api/users', async (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const limit = parseInt(req.query.limit) || 20;
  const offset = (page - 1) * limit;

  const { count, rows: users } = await User.findAndCountAll({ limit, offset });
  res.json({ users, pagination: { page, limit, totalPages: Math.ceil(count / limit) } });
});

Cursor-based pagination (fitness 98, the highest-ranked option) is offered as an alternative - it avoids a subtle performance problem with offset-based pagination (the database still has to scan past all the skipped rows even though it doesn’t return them), which matters more the deeper into the results you page:

// GOOD: no offset penalty, works well for infinite-scroll style UIs
app.get('/api/users', async (req, res) => {
  const cursor = req.query.cursor;
  const where = cursor ? { id: { [Op.gt]: cursor } } : {};
  const users = await User.findAll({ where, limit: 21, order: [['id', 'ASC']] });
  const hasMore = users.length > 20;
  res.json({ users: users.slice(0, 20), pagination: { hasMore } });
});

A streaming response option (fitness 95) is also available for large exports, where you want to start sending data immediately instead of loading everything into memory first.

large_return_payload

Similar to large_api_payload, but for regular functions (not necessarily an HTTP handler) that return unbounded query results - a helper function used elsewhere in your codebase, for instance.

// BAD
async function getUsers() {
  return await User.findAll();
}

The fix offers a reusable pagination wrapper, a DTO (Data Transfer Object) pattern for explicitly controlling what shape gets returned, or - if your codebase uses Prisma - a version using Prisma’s take/skip pagination directly.


9. DOM Manipulation Detector

File: backend/src/detectors/dom-manipulation-detector.ts Solution generator: backend/src/generators/dom-manipulation-solution-generator.ts Issue types: 5 (4 covered here + dom_manipulation_in_loop, documented in section 4 above since its fix comes from the Inefficient Loop generator)

What it’s looking for

Browser rendering is expensive and mostly synchronous. This detector catches patterns that either cause unnecessary layout recalculations (“reflows”), block page parsing, repeat expensive DOM lookups, or - in one case - introduce a real security vulnerability.

forced_synchronous_layout

Reading a layout-triggering property (like .offsetHeight or the result of .getBoundingClientRect()) right after writing to .style. The browser normally batches style changes and applies them lazily before the next paint - but if you ask it a layout question in between, it’s forced to calculate the layout immediately, synchronously, right then. Do this repeatedly (read, write, read, write…) and you get what’s called “layout thrashing.”

// BAD: the write forces the browser to defer layout, then the read
// immediately forces it to calculate that layout right now instead
element.style.left = '10px';
const rect = element.getBoundingClientRect(); // forces synchronous layout

The fix (batch-reads-before-writes, fitness 85): do all your reads first, then all your writes - or if a read genuinely has to happen after a write, defer it to the next animation frame.

// GOOD: read first, then write - no layout is forced in between
const rect = element.getBoundingClientRect();
element.style.transform = `translateX(${rect.width}px)`;

// or, if you must read after writing:
requestAnimationFrame(() => {
  const measured = element.getBoundingClientRect();
});

innerhtml_user_input - the one security-critical issue type in this entire catalog

Assigning to .innerHTML with a value that includes user-controlled input. This is a critical-severity cross-site scripting (XSS) vulnerability - a value like <img src=x onerror=alert(1)> gets parsed and executed as real markup/script, not treated as plain text.

// BAD: if userInput contains HTML/script, it runs
element.innerHTML = userInput;

The fix offers two options depending on what you actually need. If you only need to display text (most common case), textContent never parses its value as markup at all - it’s the simplest, safest fix:

// GOOD: never interpreted as HTML, completely safe from this vulnerability
element.textContent = userInput;

If you genuinely need to render user-authored HTML (e.g. rich text), sanitize it first with a library like DOMPurify, which strips dangerous content (script tags, event handler attributes) before it reaches innerHTML:

// GOOD: sanitized first, so dangerous content is stripped before rendering
import DOMPurify from 'dompurify';
element.innerHTML = DOMPurify.sanitize(userInput);

document_write

document.write() - a very old API that blocks HTML parsing while it runs, and is outright disallowed in some contexts (after the page has finished loading, inside sandboxed iframes).

// BAD
document.write(html);

The fix (replace-document-write, fitness 85): target a specific element with a modern DOM method instead.

// GOOD
const container = document.getElementById('target');
container.insertAdjacentHTML('beforeend', html);
// Note: insertAdjacentHTML carries the same injection risk as innerHTML
// for untrusted content - sanitize first if it isn't fully trusted.

dom_query_in_loop

Calling document.querySelector() (or similar) inside a loop, searching for the same thing on every iteration instead of once.

// BAD: re-searches the DOM on every single iteration
for (const item of items) {
  const container = document.querySelector('.item-container');
}

The fix (cache-query-outside-loop, fitness 90):

// GOOD: found once, reused every iteration
const cachedResult = document.querySelector('.item-container');
for (const item of items) {
  // use cachedResult here instead
}

10. Bundle Size Detector

File: backend/src/detectors/bundle-size-detector.ts Solution generator: backend/src/generators/bundle-size-solution-generator.ts Issue types: 6 (heavy_package_import, namespace_import_treeshakable, unused_import, partially_unused_import, dynamic_import_opportunity, multiple_subpath_imports)

What it’s looking for

Everything imported into a frontend file typically ends up shipped to the browser as part of your JavaScript bundle. This detector looks at your import statements and flags ones that are adding more to that bundle than necessary - whether that’s an entire heavy library, an unused import that’s dead weight, or a pattern that defeats “tree-shaking” (the bundler’s ability to remove code that’s never actually used).

Why it matters

Every 100KB of extra JavaScript adds roughly a second of load time on a typical 3G connection, and slow-loading pages get penalized in search rankings and cause real user abandonment. Unlike most of the other detectors in this catalog, the cost here isn’t runtime CPU or memory - it’s what the user has to download before your app can even start running.

heavy_package_import

Importing a known-heavy package where a lighter alternative exists (a classic example: moment at roughly 300KB, versus date-fns or dayjs at a few KB).

// BAD
import moment from 'moment';

The fix surfaces the detector’s own suggested alternative directly:

// Before:
import moment from 'moment';

// After: date-fns or dayjs (~2-7KB)

namespace_import_treeshakable

import * as _ from 'lodash' pulls in the entire library, no matter how much of it you actually use - this specifically defeats tree-shaking, since the bundler can’t tell which parts of _ you actually reference.

// BAD
import * as _ from 'lodash';

The fix switches to named imports, which the bundler can tree-shake:

// GOOD (replace 'specificFunction' with what you actually use)
import { specificFunction } from 'lodash';

unused_import and partially_unused_import

The detector actually checks whether imported names are used anywhere in the file. If an import is entirely unused, the fix is just deletion:

// Before: import { foo } from 'bar'; (nothing in this file uses `foo`)
// After: (this line is removed entirely)

If some of the named imports are used and others aren’t, the generator reconstructs the import line keeping only the used ones:

// Before: import { a, b, c } from 'pkg'; (only `a` and `c` are used)
// After:
import { a, c } from 'pkg';

dynamic_import_opportunity

An import that’s only used conditionally in the code, but is still a static (always-loaded) import - meaning it ships in the initial bundle even on the code paths where it’s never used.

// BAD: ships in every page load, even if the condition is rarely true
import Heavy from 'heavy-lib';

The fix: load it lazily with import() instead, so it’s only fetched when actually needed.

// GOOD
if (condition) {
  const module = await import('heavy-lib');
  // use module.default or its named exports
}

multiple_subpath_imports

Several separate imports from different subpaths of the same package (e.g. lodash/debounce, lodash/throttle, lodash/merge as three separate import lines).

The fix suggests consolidating into a single import from the package root, if the package supports it - reducing duplicate module resolution work.


11. ReDoS Detector

File: backend/src/detectors/redos-detector.ts Solution generator: backend/src/generators/redos-solution-generator.ts Issue types: 2 (redos_vulnerability, regex_user_input)

What it’s looking for

ReDoS stands for Regular Expression Denial of Service. Certain regex patterns - especially ones with nested or overlapping repetition, like (a+)+ - can take exponentially longer to fail to match as the input string grows, because the regex engine ends up trying an enormous number of ways to backtrack through the pattern. A pattern that runs instantly on a 20-character string might take minutes on a 40-character crafted string, and it keeps getting worse from there. Since Node’s regex engine runs on the same single thread as everything else, one slow regex match can block your entire server.

// This pattern looks innocent, but is a classic ReDoS trap
const pattern = /(a+)+b/;
// A string like "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaac"
// (lots of a's, then a character that ISN'T "b") can make this take
// an extremely long time to fail

Why this is treated differently from every other detector

This is the one place in the whole catalog where the generator deliberately does not try to automatically rewrite the problematic code. Every other detector’s generator produces a direct replacement. Here, that would be actively dangerous: correctly eliminating catastrophic backtracking requires understanding what the regex is actually supposed to match - a mechanical rewrite (like naively flattening (a+)+ to a+) can silently change what the pattern matches in ways that pass your existing tests but break in production. A “fix” that quietly changes matching behavior is worse than the original vulnerability, because at least the vulnerability is honest about being broken.

redos_vulnerability

The regex pattern itself looks structurally dangerous (nested quantifiers, overlapping alternatives).

The fix offers two strategies instead of a rewritten pattern:

  1. Input length guard (fitness 80) - a safe mitigation that works no matter how the regex itself eventually gets fixed, since it limits the attack surface before input ever reaches the regex engine:
const MAX_INPUT_LENGTH = 1000; // tune to what's realistic for this field
if (input.length > MAX_INPUT_LENGTH) {
  throw new Error('Input too long');
}
// ... then the original regex code ...
  1. Manual review guidance (fitness 60, intentionally the lower-ranked option since it’s guidance, not a fix) - surfaces the detector’s specific findings and the exact flagged pattern, unmodified, along with pointers on what to actually look for:
// REVIEW NEEDED - not an automatic fix. Flagged pattern: /(a+)+b/
//
// Suggested approach:
//   1. Check the pattern against a tool like safe-regex or regex101.com's
//      debugger (look for exponential step counts on crafted input).
//   2. Look for nested quantifiers like (a+)+ or (.*)+ and flatten them -
//      often the outer group adds nothing the inner one didn't already do.
//   3. Prefer explicit, bounded character classes over .* / .+ where the
//      set of valid characters is actually known.

regex_user_input

A regex being applied to input that looks like it comes from an untrusted source (a request body, query parameter, etc.), regardless of whether the pattern itself looks obviously dangerous - user input is unpredictable, so even a moderately complex pattern is worth guarding.

The fix is the same input-length-guard idea, applied at the call site:

const MAX_INPUT_LENGTH = 1000;
if (input.length > MAX_INPUT_LENGTH) {
  throw new Error('Input too long');
}
pattern.test(userInput);

12. Known gaps

As of this update, there are none - every issue type across all 11 detectors (49 issue types in total) now maps to a dedicated, context-aware solution generator. GenericSolutionGenerator still exists as an error-path fallback (used only if a dedicated generator throws), but nothing routes to it under normal operation.

For the record, since this reflects real project history: array_push_in_loop and object_keys_with_lookup (both from the Inefficient Loop Detector) were the last two gaps, found while writing this guide. They’ve since been closed - InefficientLoopSolutionGenerator now converts push()-in-a-loop to .map() (or .reduce() for a conditional push), and rewrites Object.keys(obj).includes(x)-style lookups into a direct x in obj check.


13. Glossary

AST (Abstract Syntax Tree) - your source code, parsed into a tree data structure that represents its actual grammatical meaning, rather than raw text. All detectors work by walking this tree looking for specific shapes/patterns, using a library called Babel.

Issue - the record a detector creates when it finds a problem: what type of problem, where in the file, how severe, and some metrics about it.

Solution - a piece of generated fix code for an Issue, along with a fitness score, risk level, and explanation.

Fitness score - a 0-100 number ranking how good a solution is, considering things like how well it preserves the original code’s variable names and structure, and how directly it solves the problem.

Detector - a class that walks the AST looking for one category of problem (e.g. N1QueryDetector looks for N+1 query patterns).

Solution generator - a class that takes an Issue and produces one or more Solutions. This codebase has two architectural styles:

ORM (Object-Relational Mapper) - a library (Prisma, Sequelize, Mongoose, TypeORM) that lets you query a database using method calls instead of writing raw SQL.

Severity - how bad an issue is: critical, high, medium, or low. Assigned per-issue based on factors like how many times a bad pattern repeats, whether it’s in a hot path, or whether it’s a security vulnerability.

Big-O notation - a way of describing how an algorithm’s cost grows as its input grows. O(n) means cost grows linearly (double the input, double the cost); O(n²) means cost grows quadratically (double the input, quadruple the cost) - this is why nested loops and array-lookups-inside-loops are so dangerous at scale, even though they look harmless with small test data.

Reflow / layout thrashing - when the browser has to recalculate the visual position and size of elements on the page. Expensive, and can happen many times per frame if you alternate reading and writing layout-affecting properties.

Event loop - the mechanism Node.js uses to handle many operations concurrently on a single thread, by handing off I/O work and coming back to it later instead of waiting synchronously. Anything that blocks this loop (synchronous file/crypto/process operations) freezes the entire server while it runs.

Tree-shaking - a bundler’s ability to detect and remove code that’s imported but never actually used, so it doesn’t get shipped to the browser. Certain import patterns (like import * as) defeat this.


This guide reflects the detector and generator source code as of the current backend implementation. All 49 issue types across all 11 detectors have dedicated, context-aware solution generation - see Known Gaps for the (now empty) record of what wasn’t true a moment ago. The full list of issue types is in the Issue Type Catalog.