DEV Community

Cover image for Why Your Node.js API Slows Down at Scale - And What Actually Fixes It
Olivia Parker
Olivia Parker

Posted on

Why Your Node.js API Slows Down at Scale - And What Actually Fixes It

The servers are fine. The code looks fine. Everything was fine three months ago.

That's the part that makes Node.js performance problems at scale so disorienting — the degradation is gradual enough that there's no obvious moment where something broke. Response times that were 80ms are now 340ms. The team adds a server instance, it helps for two weeks, then the times creep back up. Someone suggests rewriting the whole thing in Go. Someone else says the database is the problem. Nobody is completely wrong and nobody is completely right.

I've been in this situation. More than once. And the thing I've learned is that Node.js APIs slow down at scale for specific, diagnosable reasons — not random ones, not mysterious ones — and most of them are fixable without switching languages or tripling your infrastructure budget. But you have to know where to look, because the symptoms rarely point directly at the cause.

If you're dealing with this right now, or if you're trying to hire dedicated Node.js developers who can actually solve performance problems rather than just talk about them in interviews, here's what I'd tell you.

Start With the Event Loop. Seriously, Start There.

Node.js is single-threaded. Everyone knows this. What everyone doesn't internalize is what it actually means in practice when something goes wrong.

The event loop is how Node.js handles concurrency — while one request waits on a database response, the event loop picks up the next request and starts processing it. This works well. It works until something runs synchronously on the event loop and doesn't yield. Heavy JSON parsing on large payloads. Certain crypto operations. Synchronous file reads that someone put in years ago and nobody questioned because the files were small then. bcrypt password hashing with a work factor that made sense at lower traffic. These things block the event loop — not metaphorically, literally — while they're running, every other request waits.

In development with twenty concurrent users this is invisible. The blocking operation takes 60ms, nobody notices. At scale with 500 concurrent requests, that 60ms operation is a wall that everything queues behind.

Clinic.js is where I start when I suspect this. Specifically Clinic Doctor — run it against your API under load and it will tell you directly whether event loop delay is happening and how bad it is. The fix depends on what's blocking. Worker threads for CPU-heavy work. Async versions of any synchronous operations. Sometimes just finding the one crypto call that's running synchronously and moving it.

This is the one people skip because it feels too simple. It's responsible for more Node.js performance issues than anything else I've seen.

Your Database Queries Were Written Against Small Data

Not trying to be harsh about this. It's just what happens.

A query that takes 3ms on a table with 50,000 rows takes 900ms on the same table with 15 million rows, if there's no index on the column you're filtering by. The query didn't change. The data grew. The application developer who wrote the query wasn't thinking about what it would look like at scale because at the time, it wasn't at scale.

The N+1 pattern is the specific version of this that causes the most pain. You fetch a list of 50 records. Then for each record you make another database call to get related data. That's 51 queries where 2 would have done the job. On a small dataset this is an anti-pattern that happens to work. On a large dataset under real load it's the thing that makes your API time out.

Slow query logs. Turn them on. Set the threshold to 100ms and look at everything that shows up. Some of it will need indexes — check your query plans, find the sequential scans, add the indexes. Some of it will need to be rewritten. Some of it will need to be cached.

Connection pool sizing is the other database thing worth checking and it's almost never talked about. Most database client libraries have a default pool size that's conservative. If your application is handling significant concurrent load and your pool is set to 5 or 10 connections, requests are queueing for a connection before they even touch the database. Check your pool configuration against your actual concurrency numbers.

The Middleware Stack Nobody Has Audited in Two Years

Every application accumulates middleware. It happens gradually — someone adds logging, someone adds authentication, someone adds a request validation layer, someone adds rate limiting, someone adds CORS handling. Each one individually is fast. Together they run on every single request and the cumulative latency adds up.

The specific thing worth checking: how much of your middleware runs globally when it only needs to run on specific routes?

Authentication middleware running on a public health check endpoint. Body parsing running on routes that only accept query parameters. Heavy request logging running on endpoints that get called a hundred times a minute by monitoring systems. None of this is catastrophic individually. It's cumulative overhead that grows with every request your API handles.

Apply middleware at the route level where possible. Not globally. This requires actually knowing what your middleware stack looks like, which — at a lot of teams — nobody has a complete picture of because it was assembled incrementally by different people at different times.

Memory Leaks Are Slow and Patient and They Win Eventually

The thing about memory leaks in Node.js is they're invisible in development. The process gets restarted too frequently for the leak to accumulate to anything meaningful. In production, a process running continuously under load for several days is a completely different situation.

Memory climbs. Slowly at first. Then the garbage collector starts working harder, spending more time collecting and less time running your application code. Response times get worse in a way that correlates with process uptime rather than traffic spikes — which is a useful diagnostic signal, actually. If your API is slower on Tuesday afternoon than Monday morning and nothing changed in between, uptime-correlated memory pressure is worth investigating.

Common sources I've actually seen: event emitters where listeners get added and never removed. Caches implemented as plain objects or Maps with no eviction strategy — they just grow forever. Closures holding references to large objects longer than the code author realized. Global state that accumulates request data without clearing it.

Heap snapshots in Chrome DevTools, taken over time with the application under load, show you what's growing. It's slow diagnostic work. There's no shortcut. But the engineers who've done it before know what they're looking at and move through it much faster than engineers doing it for the first time.

"We Scaled Horizontally" — Did You Though

Adding servers helps. Unless the application was never actually stateless.

Session data stored in-process. User state cached in memory. Request context that gets set once and assumed to be there for the life of that user's session — but "life of the session" was implicitly defined as "life of the process" without anyone saying so explicitly.

When you add a second server, requests that were all hitting Server A now get load balanced across both. A user whose session is in Server A's memory gets a different response when their next request goes to Server B. The fix is either sticky sessions — routing each user to the same server consistently — or actually externalizing the state to Redis so any server can handle any request.

Sticky sessions work until the server goes down. Which it will. So really the only actual fix is the Redis route.

This is a common situation in applications that started as single-server deployments and grew. The original decision to store state in-process made sense at the time. Nobody flagged it when horizontal scaling became the plan.

What This Has to Do With Hiring

Here's the honest version of this.

All of these problems are findable. All of them are fixable. The variable is how long it takes and how much production pain accumulates while you're working through it. Engineers who have diagnosed Node.js performance problems before — really diagnosed them, in production, under pressure, with actual users affected — have instincts that are hard to develop any other way.

They know which symptom points to event loop blocking versus database saturation versus a memory leak. They've used the profiling tools enough to read the output without spending an hour figuring out what they're looking at. They've made the wrong call on a performance optimization before and know what that failure looks like.

When you hire dedicated Node.js developers for a system that matters at scale, the interview questions worth asking are operational. How would you find out if the event loop is being blocked? Describe a memory leak you actually tracked down. What would you look at first if an API that was fast last week is slow this week and nothing in the code changed? These questions surface the difference between someone who understands Node.js performance and someone who has read about it.

Working with Hyperlink InfoSystem to bring on dedicated Node.js developers means the screening is built around exactly that — production exposure, not just API familiarity. The performance problems this post describes are the ones that show up in real systems. The engineers who've dealt with them show up differently in production than the ones who haven't.

Top comments (0)