
So, a few months back I got handed a legacy Node.js API that was averaging around 1.4 seconds per request on some of our heavier endpoints. Users were complaining, the frontend team was slapping loading spinners on everything to hide the pain, and the initial instinct from pretty much everyone, including me if I'm honest, was "we need to migrate the database." We didn't. Got the average down to around 420ms without touching the schema or swapping the database engine at all. Writing this up because I think a lot of teams jump to the expensive fix before ruling out the cheap ones.
Quick context: this was an order management API for a mid-sized e-commerce client, built maybe four years ago, handed off between a couple of different dev teams over that time, the usual story. Nobody fully owned it anymore. That's often exactly the kind of codebase where these problems hide in plain sight.
Step one, actually profile instead of guessing
Everyone has a theory about what's slow before they've looked at any numbers. I did too, honestly, my first guess was the database. So, I stopped guessing and started measuring. Threw some console.time blocks through the request lifecycle initially just to get a rough shape of where time was going, then moved to proper APM tracing once I had a hypothesis worth confirming.
console.time('db-query');
const result = await db.query(sql, params);
console.timeEnd('db-query');
console.time('serialization');
const payload = serializeResponse(result);
console.timeEnd('serialization');
Turns out the database query itself was taking maybe 80-120ms. Not amazing, but nowhere near the villain everyone assumed it was. The real time sink was everywhere else, N+1 queries hiding inside a "single" endpoint call, redundant serialization work, and basically zero caching on data that barely changes minute to minute.
Fix 1: killed the N+1 queries with proper eager loading
This was the biggest single win by a wide margin. The endpoint was fetching a list of orders, then looping through and firing off a separate query for each order's line items. Classic N+1, and with pagination set to 50 items per page, that's 51 sequential round trips to the database for what should've been one API call.
// before - N+1 disaster
const orders = await Order.findAll();
for (const order of orders) {
order.items = await OrderItem.findAll({ where: { orderId: order.id } });
}
// after - single query with join
const orders = await Order.findAll({
include: [{ model: OrderItem, as: 'items' }]
});
This one change alone cut response time nearly in half on the heaviest endpoint. I know N+1 queries are a well-worn topic in performance write-ups, almost a cliché at this point, but I keep finding them in production codebases anyway, so clearly the lesson hasn't fully landed industry-wide.
Fix 2: added a caching layer for data that doesn't need to be real-time
A decent chunk of the response payload was product metadata, stuff that updates maybe once a day if that. There was no good reason to hit the database for it on every single request.
const CACHE_TTL = 300; // 5 minutes
async function getProductMetadata(productId) {
const cached = await redis.get(`product:${productId}`);
if (cached) return JSON.parse(cached);
const data = await Product.findByPk(productId);
await redis.set(`product:${productId}`, JSON.stringify(data), 'EX', CACHE_TTL);
return data;
}
Simple, almost boringly so, but it removed a huge amount of repeated, unnecessary work from the hot path.
Fix 3: trimmed the response payload to what the frontend actually uses
Turned out the API was returning full ORM objects with dozens of fields the frontend never touched, half of them internal flags nobody remembered adding. Serializing all of that, especially nested relations, was adding measurable overhead on every single request. Switched to explicit response DTOs instead of just dumping the model.
function toOrderResponse(order) {
return {
id: order.id,
status: order.status,
total: order.total,
items: order.items.map(i => ({ name: i.name, qty: i.qty, price: i.price }))
};
}
Smaller payloads, faster serialization, faster network transfer on top of that. All three add up more than people expect, especially on mobile connections.
Fix 4: connection pooling had been misconfigured the whole time
Found the pool size still sitting at the driver's default, way too low for our actual concurrent load. Requests were literally queuing for a free connection during traffic spikes, which doesn't show up clearly when you're profiling a single isolated request, only under real concurrent load.
const pool = new Pool({
max: 25, // was defaulting to 10
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000
});
This is the kind of thing that's easy to overlook because it hides behind the symptoms rather than causing an obvious error.
What I'd tell anyone facing a similar problem
Profile before you assume anything. Everyone's first instinct is to blame the database, and sometimes it genuinely is the culprit, but more often it's the code sitting around the query that's the real problem, redundant calls, missing caching, bloated payloads, connection handling nobody's revisited in years. A full database migration is expensive, risky, and honestly avoidable more often than teams think, especially when nobody's bothered to profile first.
If you're maintaining an older system and running into similar complaints, it's worth getting a second, less attached set of eyes on it before committing to a rewrite. This kind of performance audit is something we do fairly often at Mittal Technologies, working alongside teams that just need someone to actually trace the problem rather than guess at it. If there's a genuine architectural issue underneath, that's usually where a proper software development company in Ludhiana earns its keep, digging into the parts nobody's had time to revisit.
A lot of legacy codebases end up desultory in their architecture, not through any single bad decision, but through years of different hands touching the same system with no shared plan. That's usually where these performance issues quietly accumulate.
If you're working on something similar and want a structured audit rather than a guess-and-check approach, teams doing custom software development in Ludhiana tend to have this exact profiling-first workflow baked into how they approach legacy handoffs, which honestly saves a lot of wasted migration effort down the line.
Curious if others have run into similar N+1 traps hiding inside seemingly simple endpoints, or connection pool defaults that nobody thought to touch. Feel free to drop your own war stories in the comments, I always enjoy hearing where these things hide in other people's codebases.
Top comments (0)