Bug Fix Report
Diagnosis: Array.prototype.forEach doesn't wait for async callbacks, so res.json(results) runs before any db.getItems call has finished. The response is sent while results is still empty.
Confidence: High. The symptom matches exactly: you get [] with no error, your logs show items arriving after the response, and it started when the for loop became forEach.
What's going wrong
The problem is in this block of the /api/orders/:userId handler:
orders.forEach(async (order) => {
const items = await db.getItems(order.id);
results.push({ ...order, items });
});
res.json(results);
Here's what happens when it runs:
forEachcalls yourasynccallback once per order. Each call runs until it reachesawait db.getItems(...), then returns a pending Promise.forEachignores what its callback returns, so it throws those Promises away. It finishes right away and never waits.- The next line,
res.json(results), runs at once. NogetItemsquery has resolved yet and nothing has been pushed, soresultsis[]and that's what gets sent. - Later, the queries finish and
results.push(...)runs. The response is already gone, which is why your logs show items arriving "after the response is sent."
Your old for loop worked because await inside a plain for/for...of loop pauses the whole handler. Inside a forEach callback, it only pauses that one callback.
There's a second problem hidden in the same code. If any db.getItems call throws, the error happens inside a Promise nobody is watching, so your try/catch never sees it. Since Node 15, an unhandled promise rejection crashes the process by default, so one failed items query could take the whole server down.
The fix
Use map to turn each order into a Promise, then await Promise.all(...) so the handler waits for all of them. It's about as short as the forEach version, it keeps the original order of orders, and errors go into your existing catch.
const express = require('express');
const app = express();
const db = require('./db');
app.get('/api/orders/:userId', async (req, res) => {
try {
const orders = await db.getOrders(req.params.userId);
const results = await Promise.all(
orders.map(async (order) => {
const items = await db.getItems(order.id);
return { ...order, items };
})
);
res.json(results);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error' });
}
});
app.listen(3000);
const orders = await db.getOrders(req.params.userId);
- const results = [];
- orders.forEach(async (order) => {
- const items = await db.getItems(order.id);
- results.push({ ...order, items });
- });
+ const results = await Promise.all(
+ orders.map(async (order) => {
+ const items = await db.getItems(order.id);
+ return { ...order, items };
+ })
+ );
res.json(results);
Alternative (one query at a time, like your old loop): if you'd rather not run the queries in parallel, this also works:
const results = [];
for (const order of orders) {
const items = await db.getItems(order.id);
results.push({ ...order, items });
}
Why this works
orders.map(async ...)returns an array of Promises, one per order, so nothing gets thrown away the way it does withforEach.await Promise.all(...)pauses the handler until everygetItemscall has finished.res.jsononly runs onceresultsis filled in.Promise.allreturns results in the same order as the input array, so the orders stay in the ordergetOrdersreturned them. With thepushversion they'd come out in whatever order the queries happened to finish.- If any
getItemscall fails,Promise.allrejects and yourcatchsends a 500. Before, that error would have been an unhandled rejection. - With under 50 orders, running the queries in parallel is fine. If
dbuses apgPool, the pool limits how many queries run at once and queues the rest. If it uses a singlepgClient, that client runs its queries one after another.
Other issues spotted
| Issue | Where | Severity | Suggested fix |
|---|---|---|---|
No authorization check: anyone can call /api/orders/<anyId> and read another user's orders |
Route handler | High (if this is exposed publicly) | Check that the logged-in user matches req.params.userId (or is an admin) before querying. Return 403 if not. |
Possible SQL injection, depending on how ./db builds its queries (not shown) |
db.getOrders / db.getItems |
High if they join strings, None if they use $1 parameters |
Make sure both use parameterized queries, e.g. pool.query('SELECT ... WHERE user_id = $1', [userId]). Never paste userId into the SQL string. |
| N+1 queries: one query for orders, then one per order for items | Route handler | Low (under 50 orders) | Optional: fetch all items in one query with WHERE order_id = ANY($1) and group them by order_id in JS. |
userId isn't validated |
req.params.userId |
Low | If IDs are integers, reject anything that isn't a number with a 400, so bad input doesn't cause a database error and a 500. |
How to verify the fix
- Restart the server and call
GET http://localhost:3000/api/orders/<userIdWithOrders>. You should get an array of orders, each with anitemsarray. - Check that the orders come back in the same order
db.getOrdersreturns them. - Keep your log inside the callback. Each order's log should now print before the response goes out.
- Edge case: call it for a user with no orders. You should get
[]with status 200, and it should be the correct answer this time. - Edge case: call it for an order with no line items. That order should come back with
items: [], assuminggetItemsreturnsrowsfrompg. - Error path: make
db.getItemsthrow temporarily, e.g. `thr