Crux Read real ORM loops, a DataLoader batch function, a query log, and a fan-out handler — predict the round-trip count and pick the highest-leverage fix.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at senior altitude — in orbit
◷ 14 min
N+1 is diagnosed by reading code and logs, not by guessing. Read the snippet or the trace, count the round-trips it will fire, then choose the fix a senior engineer reaches for first.
Goal
Practise the loop you run in every N+1 incident: read the hot path, count the round-trips, and reach for the structural fix that matches the data shape — before touching indexes or hardware.
Snippet 1 — the innocent loop
const orders = await Order.findAll({ where: { userId } }); // 50 rowsfor (const order of orders) { const customer = await order.getCustomer(); // one query per order render(order, customer.name);}
Quiz
Completed
With 50 orders, how many queries does this fire, and what is the single highest-leverage fix?
Heads-up Lazy getCustomer() inside the loop fires a query each iteration; the ORM does not batch it unless you declare the relationship up front with include/preload.
Heads-up An index speeds each lookup but you still pay 50 round-trips of latency. The fix is fewer trips — eager-load into one IN query — not faster individual ones.
Heads-up Deferring rendering changes nothing about when the queries fire. The 50 customer queries are triggered by lazy access inside the loop regardless of render timing.
Snippet 2 — the DataLoader batch function
const userLoader = new DataLoader(async (ids) => { const users = await db.user.findMany({ where: { id: { in: ids } } }); return users; // returned in DB order});// resolver: return userLoader.load(post.authorId)
Quiz
Completed
This DataLoader batches correctly but has a correctness bug a load test will expose. What is it?
Heads-up The IN query is fine and DataLoader serialises the batch. The defect is the result ordering contract: outputs must align positionally with input ids.
Heads-up The opposite — module-scope loaders leak cached data across requests. The bug in THIS snippet is the unordered return, not the instantiation scope.
Heads-up load() returns a Promise that the resolver framework awaits; the resolver returning it is correct. The bug is the misordered batch result.
Snippet 3 — the query log
Started GET "/dashboard"User Load (0.4ms) SELECT * FROM users WHERE id = 42Project Load (0.6ms) SELECT * FROM projects WHERE user_id = 42 LIMIT 50Task Load (0.3ms) SELECT * FROM tasks WHERE project_id = 1Task Load (0.3ms) SELECT * FROM tasks WHERE project_id = 2... (48 more Task Load lines)Comment Load (0.2ms) SELECT * FROM comments WHERE task_id = 1... (243 more Comment Load lines)Completed 200 OK in 980ms (Views: 250ms | ActiveRecord: 689ms)
Quiz
Completed
Reading this log, what is happening and what is the precise fix?
Heads-up 0.2 ms per query is fast. The 689 ms is the sum of ~300 round-trips, not slow individual queries. The fix is fewer queries via nested eager loading.
Heads-up Views are 250 ms but ActiveRecord is 689 ms across 300+ queries — the dominant cost. Killing the nested N+1 is the leverage, not template tuning.
Heads-up There are two levels: tasks per project AND comments per task. includes(:tasks) alone leaves the ~245 comment queries; you need includes(tasks: :comments).
These four service calls are independent (none consumes another's result). What is the wall-clock now, and the fix?
Heads-up Sequential await blocks each call until the previous resolves. They run serially at ~120 ms; you must dispatch them together with Promise.all to overlap.
Heads-up These are four different services with no shared backend to batch against. The fix for independent remote calls is parallel dispatch, not query batching.
Heads-up Protocol speed gives an incremental win; the calls remain serial. Parallelising collapses sum(latencies) into max(latencies), the structural fix.
Recap
Every N+1 is read in code or logs: a lazy relation inside a loop is 1+N queries fixed by eager loading; a DataLoader batch function must return results positionally aligned to its input ids; a query log with repeated similar SELECTs reveals nested N+1 levels fixed by nested preload; and independent serial service calls collapse from sum to max via parallel dispatch. Count the round-trips first, match the fix to the data shape, then re-read the log to confirm the count dropped.