Skip to content
Amygdal
Engineering5 min read

How to Scale a Node.js Application Without a Rewrite

Most Node.js scaling problems are not Node problems. Here is the order we work through when an API starts slowing down under real traffic.

Node.js scales well when you respect its strengths and avoid its traps. It is excellent at I/O-heavy, high-concurrency workloads, which describes most web APIs. It is poor at long CPU-bound work on the main thread. Nearly every scaling problem we are asked to fix comes back to one of a handful of causes, and very few of them are solved by simply buying a bigger server.

This is the order we work through when a Node.js backend starts struggling, drawn from projects ranging from early-stage products to high-traffic platforms such as A Place For Mom, where we built on Node.js with event-driven AWS infrastructure (Lambda, SQS, SNS) for a service used by more than 300,000 customers.

1. Measure before you change anything

You cannot scale what you cannot see. Before touching code, we make sure the service has structured logs, request metrics (latency percentiles, not just averages), distributed tracing across services and the database, and event loop lag monitoring. With that in place, the bottleneck usually announces itself.

A realistic load test against a staging environment then tells you where it breaks and at roughly what level of traffic. That is far cheaper than finding out in production.

2. Keep the event loop free

Node runs your JavaScript on a single thread. If one request spends 300 milliseconds parsing a huge JSON payload, resizing an image or generating a PDF, every other request waits. Common culprits are synchronous crypto, large JSON serialisation, regex on untrusted input and in-process report generation.

  • Move CPU-heavy work to worker threads or, better, to a background queue processed by separate workers.
  • Stream large files and responses instead of buffering them in memory.
  • Paginate everything that can grow, so no endpoint ever returns an unbounded result.
  • Watch event loop lag as a first-class metric and alert on it.

3. Run stateless and scale horizontally

Once the event loop is healthy, a Node.js API should be stateless: no sessions in memory, no files written to local disk, no in-process caches that must stay consistent across instances. Sessions go to Redis or signed tokens, uploads go to object storage such as S3 or MinIO, and shared state lives in the database.

Stateless services scale by adding instances behind a load balancer, whether that means more containers, more pods or a managed platform scaling on CPU and request count. This is the simplest, most reliable form of scaling, and it only works if the earlier steps are done.

4. Cache deliberately

The fastest request is one that never reaches your database. We layer caching from the outside in: HTTP caching and a CDN for public responses, Redis for hot data and computed results, and occasionally a short-lived in-memory cache for values that are safe to be slightly stale.

The hard part is invalidation. We prefer explicit cache keys tied to the entity that changed, short TTLs where correctness allows, and avoiding caches entirely for data that must always be fresh, such as balances or permissions.

async function getProfile(id: string) {
  const key = `profile:${id}`;
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const profile = await db.profile.findUnique({ where: { id } });
  await redis.set(key, JSON.stringify(profile), "EX", 300);
  return profile;
}

// On update: write to the database, then delete the key.
await redis.del(`profile:${id}`);

5. The database is usually the real bottleneck

Most 'Node does not scale' complaints turn out to be database problems. The usual fixes, roughly in order of effort:

  1. Add the missing indexes for the queries you actually run, confirmed with the query plan.
  2. Remove N+1 queries, which ORMs make easy to write by accident.
  3. Use connection pooling sized to the database, not to the number of Node instances multiplied by an arbitrary default.
  4. Move slow or non-urgent work off the request path into queues: emails, exports, webhooks, search indexing and AI calls.
  5. Add read replicas for read-heavy workloads once the above is done.
  6. Revisit the data model only when access patterns have clearly outgrown it.

Event-driven work at higher scale

As traffic grows, more of the system benefits from being asynchronous. Instead of one request doing five things in sequence, the API records what happened and publishes an event; independent consumers send the email, update the search index, notify a partner system and refresh analytics. Each consumer can scale, fail and retry on its own without slowing the user's request.

On AWS this is often SQS and SNS with Lambda or container workers, which is the pattern used on A Place For Mom. On smaller stacks, Redis-backed queues give most of the same benefit with less infrastructure. The important design rules are the same either way: make consumers idempotent, keep messages small and versioned, and monitor queue depth so a backlog is noticed before users feel it.

6. Split services only when there is a reason

Microservices are an organisational tool as much as a technical one. A well-structured modular monolith, for example a NestJS application with clear module boundaries, will carry most products a long way. We extract a service when a part of the system has genuinely different scaling, deployment or ownership needs, such as a media pipeline or a search indexer, not because the architecture diagram looks more impressive. We describe this structure in more detail in our post on NestJS, Prisma and PostgreSQL architecture.

A scaling checklist before launch

If you are preparing for a launch, a marketing push or an investor demo, this short list catches most of the problems we see in production:

  • Event loop lag, p95 latency and error rate are on a dashboard someone actually looks at.
  • Every list endpoint is paginated and every query behind it has an index.
  • Emails, exports, webhooks and third-party calls run in a queue, with retries and a dead-letter view.
  • The API is stateless and can run as two or more instances without code changes.
  • Timeouts are set on every outbound HTTP call, so a slow partner API cannot exhaust your connections.
  • A load test has been run against staging at a multiple of expected peak traffic.

None of these items is difficult on its own. The value is in doing all of them before traffic arrives, rather than discovering the missing one during an incident.

Getting help scaling your backend

If your Node.js API is slowing down, or you want it reviewed before a launch or funding round, we can run a focused performance and architecture review and then fix what we find. See our software development service or contact us to talk it through.

Node.jsBackendScalabilityRedisPostgreSQL

Frequently asked questions

Can Node.js handle high traffic?

Yes. Node is well suited to I/O-heavy, high-concurrency workloads. Scaling comes from running it stateless and horizontally, caching sensibly, keeping CPU work off the event loop and tuning the database.

What is the most common Node.js scaling mistake?

Blocking the event loop with CPU-heavy work, and treating the database as an afterthought. Move heavy and asynchronous work to queues and design the data layer for your real access patterns.

Do we need microservices to scale Node.js?

Usually not. A modular monolith scales horizontally very well. Extract services only when a component has clearly different scaling, deployment or ownership needs.

Keep reading

Have a project in mind?

Tell us what you are building. You will hear back from an engineer — not a sales team — within one business day.

Email us