BE3: Fast queries

Lesson 3 of 5 · reading 15–25 minutes; practice and project time is on the module page

The query-per-item trap

🎯You'll be able to

Spot the "N+1 queries" pattern and replace it with one query that fetches everything with joins or aggregates.

🏠The everyday picture

A shopkeeper who walks to the godown once for each item on a 30-item list makes 30 trips. One trip with the whole list takes a fraction of the time, even if that trip is longer.

📘What it really is

📝A worked example

Starter code in the kit's api/menu.js, simplified:

const items = (await pool.query('SELECT ... FROM menu_items WHERE kitchen_id = $1', [id])).rows;
for (const it of items) {
  it.rating = (await pool.query('SELECT avg(stars), count(*) FROM ratings WHERE menu_item_id = $1', [it.id])).rows[0];
  it.orders_30d = (await pool.query('SELECT sum(qty) FROM order_items ... WHERE menu_item_id = $1 ...', [it.id, id])).rows[0];
}

One query instead, in outline:

WITH counts AS (SELECT oi.menu_item_id, sum(oi.qty) AS n FROM orders o JOIN order_items oi ON oi.order_id = o.id
                WHERE o.kitchen_id = $1 AND o.placed_at >= $2 GROUP BY oi.menu_item_id),
     stars  AS (SELECT r.menu_item_id, avg(r.stars) AS avg, count(*) AS count FROM ratings r
                JOIN menu_items m ON m.id = r.menu_item_id WHERE m.kitchen_id = $1 GROUP BY r.menu_item_id)
SELECT m.*, coalesce(c.n, 0) AS orders_30d, coalesce(s.avg, 0) AS rating_avg, coalesce(s.count, 0) AS rating_count
FROM menu_items m LEFT JOIN counts c ON c.menu_item_id = m.id LEFT JOIN stars s ON s.menu_item_id = m.id
WHERE m.kitchen_id = $1 ORDER BY m.id;

Input: POST /_stats/reset, then GET /v1/kitchens/7/menu, then GET /_stats. Expected output: 62 queries with the starter; 2 with the combined query (one for the kitchen, one for the menu). The LEFT JOINs keep dishes with no orders or ratings, with 0 instead of missing.

🌍Where it's used

📝Case scenario

A team fixes N+1 with one big query but uses JOIN instead of LEFT JOIN. Dishes nobody has rated yet vanish from the menu, and a new kitchen's menu appears empty. Kitchen owners complain before anyone notices the speed gain. Comparing the new output with the old for several kitchens, as the kit's cache test 1 does, catches it.

🖱️Try it

In the kit, reset /_stats, load one menu and read the query count. Then write the combined query in psql for kitchen 7 and check its result against the starter's for three dishes. Success: same numbers, two queries instead of 62.

⚠️Common mistakes

🛠️For your project

Project 2 test 3 allows at most 3 queries for a cold menu request; the starter makes 62.

✅Quick check

How many queries does an N+1 page make for 30 items with two lookups each?

Show the answer

1 + 30 × 2 = 61 (62 in the kit, which also looks up the kitchen).

Why LEFT JOIN rather than JOIN in the combined menu query?

Show the answer

To keep dishes that have no orders or ratings, showing 0 instead of dropping them.

← PreviousIndexes that earn their keep
ProLearners Assist