code Review/performance/N+1 Queries, Unbounded Memory, and Hot Loops: Spotting Performance Defects in Review
performance

N+1 Queries, Unbounded Memory, and Hot Loops: Spotting Performance Defects in Review

Performance defects are invisible at fixture scale and unavoidable at production scale. Learn the loop-and-bound reading method, plus when a performance comment needs a measurement first.

Reviewer Detection Checklist

0 of 12 verified (0%)

Defect Patterns & Fixes

#1

N+1 query hidden inside a loop over line items

java
Scenario: An order service checks and deducts stock for every item in an order.
public void reserve(Order order) {
    for (OrderItem item : order.getItems()) {
        int stock = inventoryRepository.getStock(item.getProductId());   // 1 query
        inventoryRepository.updateStock(item.getProductId(),
                                        stock - item.getQuantity());     // 1 more
    }
}
Why this is defective: Two round trips per line item means a 200-item order issues 400 queries. Each one is fast in isolation, which is why this passes every test and every profiler run against fixture data; the damage appears as connection pool exhaustion under concurrency, and the resulting timeouts hit endpoints that have nothing to do with orders. Note that the batched version also fixes a correctness defect that the loop made easy to miss — the original never checks that stock is sufficient, so it drives inventory negative. Batching forced the validation to become explicit.
How to Spot It in Reviews:
  • For every loop in a diff, name what is inside it. A repository, client, or session call is an N+1 by definition.
  • ORM lazy loading makes the query invisible: order.getItems() inside a loop may itself be the N+1.
  • Multiply by a realistic collection size and say the number out loud in the comment — '400 queries per order' is actionable, 'this is slow' is not.
  • Read-then-write per item is also a lost-update race; performance and correctness findings often sit on the same line.
#2

Whole file read into memory

python
Scenario: An ingestion pipeline chunks a log file for parallel parsing.
def process_large_file(self, file_path: str) -> dict[str, int]:
    with open(file_path) as f:
        all_lines = f.readlines()          # entire file resident

    chunk_size = 1000
    chunks = [all_lines[i:i + chunk_size]
              for i in range(0, len(all_lines), chunk_size)]
    ...
Why this is defective: readlines() materialises every line, and the chunk list comprehension then holds a second set of references to all of them, so peak memory is proportional to file size at a multiple greater than one. On a multi-gigabyte log this is an OOM kill: the process dies without a stack trace, mid-write, and the orchestrator restarts it to do the same thing again. Iterating the file object reads lazily and keeps only one chunk resident, and the fix is smaller than the code it replaces.
How to Spot It in Reviews:
  • readlines(), read(), ReadAllLines, ioutil.ReadAll, and .ToList() on anything whose size is not bounded by the code.
  • A slicing or chunking step immediately after a full read usually doubles the peak, not halves it.
  • Ask for the p99 input size in production, not the size of the test fixture.
  • Symptom to correlate: the container is killed with no application error and the incident is blamed on the orchestrator.
#3

Per-item access to shared cross-process state

python
Scenario: Worker processes accumulate status-code counts into a multiprocessing manager dictionary.
def parse_line_batch(lines: list[str], shared_stats) -> None:
    for line in lines:
        status = line.split(" ")[3]
        # Every read and every write is an IPC round trip through a proxy.
        if status in shared_stats:
            shared_stats[status] += 1
        else:
            shared_stats[status] = 1
Why this is defective: A Manager.dict is a proxy: each access is serialised, sent to the manager process, and awaited. Doing that twice per line turns a CPU-bound parse into an IPC-bound one, and because every worker is queuing on the same manager, adding processes makes throughput worse — the counter-intuitive symptom that sends people looking for a hardware problem. The read-then-write is also a race across processes, so the counts are wrong as well as slow. Accumulating locally and merging once removes the contention, the IPC, and the race together, which is the general shape of the fix for shared-counter contention in any language.
How to Spot It in Reviews:
  • Any shared, proxied, or remote structure accessed inside a per-item loop — manager dicts, Redis, a distributed cache, a metrics backend.
  • Check whether the loop body's real work is smaller than the coordination around it.
  • 'It gets slower when we add workers' is the signature of contention, not of insufficient parallelism.
  • Local accumulation plus a single merge is almost always available; propose it concretely rather than asking for 'less locking'.
#4

Unbounded cache keyed by user input

csharp
Scenario: A currency service memoises exchange-rate lookups to avoid repeat calls to a paid API.
private static readonly Dictionary<string, decimal> _rates = new();

public decimal GetRate(string from, string to)
{
    var key = $"{from}:{to}:{DateTime.UtcNow:yyyy-MM-dd-HH-mm}";
    if (_rates.TryGetValue(key, out var cached)) return cached;

    var rate = _api.FetchRate(from, to);
    _rates[key] = rate;
    return rate;
}
Why this is defective: Two defects compound. Putting a minute-resolution timestamp in the key means entries are never reused for long and never removed, so the dictionary grows by a new entry every minute for every currency pair, forever — a memory leak whose rate is proportional to traffic. And because it is a static Dictionary written from concurrent request threads, it can corrupt internally and spin on lookup, which presents as a pegged CPU core with no obvious cause. A cache needs three things stated: a key that repeats, an expiry, and a size bound.
How to Spot It in Reviews:
  • Any long-lived dictionary or map that is written but never read for removal — grep for the absence of eviction, not the presence of a bug.
  • Time or request-scoped values inside a cache key defeat the cache while keeping the growth.
  • A static mutable collection touched by request threads is a thread-safety finding as well as a memory one.
  • Ask for the cardinality of the key space: if it is user-supplied, the bound is 'unbounded' until proven otherwise.

Why performance defects have to be caught by reading, not measuring

Performance defects have a property that makes review uniquely valuable: they are invisible at development scale and unavoidable at production scale. A query inside a loop is instant with the ten rows in your fixture and catastrophic with the ten thousand rows a real tenant has. Nothing in local testing, CI, or a code health dashboard distinguishes the two — but the shape is right there in the diff, and the shape is what you review.

The other reason to catch them early: performance defects are usually architectural in miniature. Fixing an N+1 after release often means changing a data access pattern, a serialisation format, or an API contract, which is a much bigger change than adding a JOIN FETCH during review.

What it costs in production

  • Latency that scales with the customer's success. Your best customer has the most data and therefore the worst experience. The complaints arrive from exactly the accounts you cannot afford to lose.
  • Out-of-memory kills. Loading a whole file or result set into memory works until a file is large, then the process is killed by the OOM killer mid-write, leaving partial state and no stack trace.
  • Connection pool exhaustion. An N+1 turns one request into 500 queries. At fifty concurrent requests the pool is empty and unrelated endpoints start timing out — the outage looks like a database problem, not a code problem.
  • Cost that compounds silently. Extra queries, extra egress, extra instances. Nothing breaks, so nothing gets prioritised, and the bill grows quarterly.
  • Lock contention and thrash. Work that is technically parallel spends its time coordinating; adding workers makes it slower, which is deeply counterintuitive during an incident.
  • Cascading timeouts. A slow endpoint holds threads, which fills the queue, which trips upstream timeouts and retries, which multiplies the load that caused it.

How to spot it in review

Read for the number of times something happens and the amount of data resident at once. Almost every performance defect is one of those two.

Loops that contain I/O. The highest-yield pattern in application code. For each loop in the diff, ask what is inside it: a query, an HTTP call, a file read, a cache miss, a log flush, or a lock acquisition. A single call inside a loop over user data is an N+1.

  • ORM lazy loading is the sneakiest version — order.getItems() looks like a field access and is a query. In the diff you see a for loop with no visible I/O at all.
  • GraphQL resolvers and serialiser to_representation hooks have the same property: the loop is in the framework, not in the diff.

Unbounded reads. readlines(), read(), findAll(), ToList() on an unfiltered set, SELECT * without a LIMIT, buffering an entire response body. Ask: what is the largest this can be in production, not in the test?

Unbounded growth. A cache, map, or list that is added to and never evicted or trimmed. A dedupe table with no retention. A retry queue with no cap. These are memory leaks with extra steps.

Work repeated per element that could be done once. Compiling a regex, building a formatter, opening a connection, or re-fetching configuration inside the loop body.

Quadratic shapes. A nested loop over two collections that both grow (for a in listA: for b in listB: if a.id == b.id) — build a map instead. String concatenation in a loop in languages where strings are immutable. list.remove(x) or contains inside a loop is O(n) per call.

Chatty or contended coordination. Per-item access to a shared/proxied structure across processes, a lock taken inside the hot loop, or a synchronous cross-service call per item.

Tip

Ask the author one question: "what is the largest realistic value of n here, and how many round trips is that?" If they do not know n, that is the finding. Most performance defects are an unstated assumption about size.

Fixing it without breaking something else

  • Eager fetching can explode the result set. JOIN FETCH on two collections produces a cartesian product; the fix for one N+1 can return ten times the rows. Batch loading or a second query is often better than one big join.
  • Batching changes failure granularity. One failed row can now fail 500 rows. Partial-failure handling, retry semantics, and idempotency all need to be revisited when you batch.
  • Streaming changes transaction and connection lifetime. A streamed result keeps a cursor and a connection open for the duration of processing, which can exhaust the pool faster than the eager read you replaced.
  • Caching introduces staleness and a new failure mode. Every cache needs an invalidation story, a bound, and an answer for what happens on a miss storm. A cache added to fix a slow query hides the slow query rather than removing it.
  • Adding an index slows writes and needs to be built without locking the table. Check whether an existing composite index already covers the query.
  • Parallelising multiplies pressure downstream. Ten concurrent workers make ten times the queries; the bottleneck moves to the database and gets harder to see.
  • Micro-optimisations can change semantics. Replacing a Decimal with a float, or a stable sort with an unstable one, is a correctness change wearing a performance costume.

How not to over-engineer the fix

This is the most over-corrected category in code review, because performance work feels productive and is easy to justify.

The measurement rule. If the defect is a shape (a query in a loop, an unbounded read, a quadratic scan), you can call it out from the diff — the complexity class is visible and the fix is local. If the concern is a constant factor ("this map lookup could be an array", "this allocates"), it needs a measurement before it justifies a comment, and probably does not belong in this review at all.

The proportionate fixes, cheapest first:

  1. Move the I/O out of the loop. One query with an IN clause, one batched fetch, one prepared statement reused.
  2. Add the bound. A LIMIT, a page size, a max upload size, a cache with a maximum entry count and a TTL.
  3. Stream instead of buffering. Iterate lines, not readlines(). Return a cursor, not a list.
  4. Aggregate locally, merge once. The standard fix for cross-process contention: each worker keeps its own counters and merges at the end, removing both the lock and the IPC.
  5. Only then consider a cache, an index, denormalisation, or parallelism.

Push back on: introducing Redis to fix a missing JOIN; async rewrites of code that is not I/O bound; object pools and hand-rolled buffer reuse in a request path that makes a network call anyway; a queue and a worker fleet for something that takes 40ms; and any optimisation whose justification is "this could be slow later" with no n attached.

Important

A performance comment without a magnitude is noise. "This is slow" cannot be acted on. "This runs one query per line item, so a 200-item order makes 200 round trips — fetch them with a single IN query" can be acted on immediately, by a human or an agent.

Comment templates that an agent can act on

  • "inventoryRepository.getStock(...) runs inside the item loop — one query per line item. Fetch all product ids in a single query before the loop and index by id."
  • "f.readlines() loads the whole file into memory; these logs are multi-gigabyte. Iterate the file object line by line instead."
  • "shared_stats is a Manager.dict mutated once per line, so every increment is an IPC round trip. Accumulate in a local dict per chunk and merge the returned dicts."
  • "This cache has no eviction or TTL and is keyed by user id — it grows without bound. Use an LRU with a maximum size."
  • "results.contains(item) inside the loop makes this O(n²) over a list that reaches ~50k. Build a HashSet before the loop."
Interactive Practice

Review Defective Code in the Workbench

Test your ability to spot this defect in our interactive Monaco-powered PR code editor.

Open Workbench