Why a 304 Response Can Still Be Slow
A 304 Not Modified saves bandwidth, not work. In Express the ETag is computed from the body your handler already built, so every query still runs. Here's how I found where the time actually went, and fixed it.
I had a slow endpoint and a browser Network tab full of 304 Not Modified. My first thought was: it's cached, so it can't be the server.
Wrong. A 304 tells you the response didn't change. It says nothing about how much work the server did to find that out.
How Express produces a 304
Express turns on weak ETags by default. When your handler calls res.json(data), this happens inside res.send:
- Your handler has already run: every query, every calculation.
- Express serialises the body and hashes it into an ETag.
- If the request's
If-None-Matchmatches that ETag, Express drops the body and replies304.
So the 304 saves the download: the bytes over the wire and the browser's parsing. It saves none of the database time. If the endpoint takes two seconds to build its response, it takes two seconds to return a 304 too.
The header that did nothing
The project financials controller in BuildLedger, the construction ERP I built, tried to turn caching off:
res.set({
"Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
Pragma: "no-cache",
Expires: "0",
});
res.removeHeader("ETag");
res.json(financials);removeHeader("ETag") has no effect here: the ETag doesn't exist yet. Express adds it a moment later, inside res.json. The Cache-Control: no-store header is what actually stops caching. The ETag line was a comment pretending to be code.
The lesson isn't about ETags. It's that I'd assumed where the time went instead of measuring it.
Step 1: measure on every request
I added a small middleware that tracks database time per request and reports it in a Server-Timing header. It uses AsyncLocalStorage so any query, anywhere in the request, can add to the right counter:
export function requestTimingMiddleware(req: Request, res: Response, next: NextFunction) {
const startedAt = performance.now();
const store: RequestTiming = { dbMs: 0, queries: 0 };
const originalWriteHead = res.writeHead;
res.writeHead = function (this: Response, ...args: any[]) {
const totalMs = performance.now() - startedAt;
if (!res.headersSent) {
res.setHeader(
"Server-Timing",
`db;dur=${store.dbMs.toFixed(1)};desc="${store.queries} queries", total;dur=${totalMs.toFixed(1)}`,
);
}
return originalWriteHead.apply(this, args as never);
};
// …run the rest of the request inside the store
}A Prisma middleware adds each query's duration and count to the store. Browsers display Server-Timing right in the request's Timing tab, so every response now says how many queries it ran and how long the database took, with no APM tool needed. Anything over a threshold also gets logged as a slow request.
Step 2: look at the shape of the queries
With numbers in front of me, the financials endpoint was easy to read. It made six database calls one after another, and the first one loaded every payment, material, labour and expense row for every project so Node could add them up:
// Before (simplified): sequential, and summing in JavaScript
const projects = await prisma.project.findMany({
include: { clientPayments: true, materials: true, labourLogs: true, expenses: true },
});
const vendorTotals = await prisma.vendorPayment.groupBy(/* … */);
const salaryTotals = await prisma.salarySlip.groupBy(/* … */);
// …three more awaitsAgainst a hosted database, every await is at least one full network round trip. Six of them in a row add up, and the row-loading one gets slower every month as the ledger grows.
Step 3: let the database do the maths, all at once
The rewrite does two things. Totals are summed in SQL with groupBy, so only one number per project comes back. And because none of the queries depend on each other, they all start at the same time:
// Every query below is independent, so they run in parallel: each one costs a full
// network round trip to the database. Ledger totals are summed in SQL rather than
// loading every row into Node.
const [projects, payments, materials, labour, expenses, vendorPayments, salaries, invoices, transfers] =
await Promise.all([
prisma.project.findMany({ select: { id: true, name: true /* … */ } }),
prisma.clientPayment.groupBy({ by: ["projectId"], _sum: { amount: true } }),
prisma.material.groupBy({ by: ["projectId"], _sum: { totalAmount: true } }),
// …the other ledgers, same pattern
prisma.fundTransfer.findMany({ /* … */ }),
]);It actually runs more queries than before, but they overlap, so the wall-clock time is closer to the slowest single query than to the sum of all of them. I also added an index on client_payments(project_id) so those grouped sums don't scan the whole table.
A few things to watch with Promise.all on a database:
- Parallel queries need parallel connections. If the pool is smaller than the batch, the extra queries just queue, so size the pool deliberately.
- Keep it to independent reads. Anything that must see a consistent snapshot belongs in a transaction.
- Test that the totals didn't change. A characterisation test pinned the exact receivables, payables and net positions before the refactor, and it had to pass unchanged after.
What a 304 is actually good for
ETags are still useful. They save bandwidth on large responses, and on slow mobile connections that matters. But if you want to skip the work, you need a cache in front of the work: an in-memory or Redis cache keyed on the data's version, or HTTP caching with max-age so the request never reaches your server at all.
The takeaway
When a request is slow, don't start with a theory. Put a Server-Timing header on every response and look at two numbers: how long the database took, and how many queries it ran. In my case they pointed straight at sequential round trips and JavaScript doing a database's job, not at caching at all.
- #nodejs
- #express
- #performance
- #postgresql
