Home / Articles / 20 Node.js Patterns That Prevent Production Server Downtime

This article is published in English.

20 Node.js Patterns That Prevent Production Server Downtime

Learn 20 practical Node.js patterns—from error handling to graceful shutdown and connection pooling—that stop crashes before restarts become necessary.

4280 words

Server downtime almost never begins with one catastrophic event.

Usually, the trigger is something quiet and unremarkable.

A connection to the database never gets returned to the pool.

Memory usage creeps upward until the process hits its ceiling.

A single route that used to respond in 200 milliseconds starts taking five seconds.

A deploy severs connections that were still active.

An async job fails without anyone catching the error.

A sudden burst of traffic forces every incoming request to fire off another costly database call.

None of this looks alarming at first.

Then response times start to slip.

Then requests begin timing out.

Then the process goes down entirely.

And that's when the questions start:

"Why is the server down?"

What makes this frustrating is that most of these situations could have been avoided.

Across many production systems, a recurring set of Node.js fixes keeps showing up as genuinely effective, precisely because they target the root cause instead of just bouncing the server back to life after it falls over.

This article walks through the ones that matter most.

1. Stop Letting One Bad Request Take Down the Process

Among the riskiest habits in a Node.js codebase is letting a thrown exception or an unhandled promise rejection bubble all the way up into the process itself.

Take this example:

app.get("/users/:id", async (req, res) => {
  const user = await User.findByPk(req.params.id);

  res.json(user);
});

It seems fine at first glance.

But what happens if the database connection drops mid-request?

Or the ORM raises an error?

Or an unexpected input value breaks the logic inside the handler?

Without consistent error handling, the process can be pushed into a broken state, and depending on the specific error and how your runtime is configured, it may shut down completely.

A more resilient pattern is to route errors explicitly instead of letting them escape:

app.get("/users/:id", async (req, res, next) => {
  try {
    const user = await User.findByPk(req.params.id);

    res.json(user);
  } catch (error) {
    next(error);
  }
});

From there, funnel everything into one centralized handler:

app.use((err, req, res, next) => {
  console.error(err);

  res.status(500).json({
    error: "Internal server error"
  });
});

This isn't about masking problems.

It's about deciding exactly where errors end up.

Rather than each route improvising its own way of dealing with failure, the whole app follows one consistent, predictable path.

2. Don't Treat Process Restarts as the Solution

When a Node.js process goes down, the instinctive fix is usually just:

npm start

Or, for teams running PM2:

pm2 restart app

That brings the service back online quickly enough.

What it doesn't do is address whatever caused it to fail in the first place.

If your app goes down every six hours because of a leak, restarting it on that schedule isn't a stable system — it's a stopgap.

Tools like PM2, Docker, Kubernetes, or a managed platform runtime can restart a crashed process automatically, which is genuinely useful.

With PM2, for instance:

pm2 start app.js --name api
pm2 save

But an automatic restart should be understood as damage control, not a fix for the underlying issue.

You still need answers to questions like:

Why did the process crash?
What request triggered it?
How much memory was being used?
What error occurred?
Was there an unhandled rejection?
Was the database unavailable?

A restart restores service for your users.

Only proper observability tells you why service was lost to begin with.

Both pieces are necessary.

3. Fix Memory Leaks Before They Become Out-of-Memory Crashes

Node.js relies on garbage collection, but that doesn't guarantee memory gets cleaned up correctly on its own.

It's entirely possible for your code to hold onto references to objects that are no longer actually needed.

Here's a simple illustration:

const cache = [];


app.get("/users", async (req, res) => {
  const users = await getUsers();


  cache.push(users);

  res.json(users);
});

Each incoming request appends more data to cache.

Because the app still holds a reference to those entries, the garbage collector has no way to reclaim them.

Over time, this looks like:

Memory usage
100 MB
200 MB
400 MB
800 MB
1.2 GB
2 GB
💥

The precise numbers will vary depending on your setup and configured limits, but the underlying pattern is what matters.

Watch out for things like:

  • Global arrays that keep growing
  • Caches with no size cap
  • Large objects kept in memory
  • Event listeners that never get detached
  • Timers that are never cleared
  • Streams left open
  • Large data retained inside closures
  • Queues with no upper bound

If your application caches data in memory, that cache needs an explicit policy.

For instance:

Maximum entries: 10,000
TTL: 10 minutes
Eviction: LRU

A cache with no limits isn't really functioning as a cache.

It's a memory problem waiting to happen.

4. Put Timeouts on External Requests

Calling another service without setting a timeout is one of the fastest ways to leave your API hanging.

Picture this call:

const response = await fetch("https://external-service.com/api");

What if that external service suddenly becomes very slow to respond?

Your own request could end up waiting far longer than it should.

Now scale that up to 1,000 concurrent users hitting the same endpoint.

Suddenly you have hundreds or thousands of requests all stuck waiting on a dependency that isn't answering.

That backlog can eventually overwhelm your own server too.

Always build in explicit timeout handling.

For example, using AbortController:

const controller = new AbortController();

const timeout = setTimeout(() => {
  controller.abort();
}, 5000);

try {
  const response = await fetch(
    "https://external-service.com/api",
    {
      signal: controller.signal
    }
  );

  return await response.json();
} finally {
  clearTimeout(timeout);
}

Now your app won't sit there waiting indefinitely.

The rule of thumb is straightforward:

Never let an external dependency dictate how long your server is willing to wait, with no limit.

This matters just as much whether you're talking to a payment gateway, an email delivery service, a generic HTTP endpoint, Redis, your own database layer, a third-party API, or another internal microservice in your infrastructure.

5. Fix Database Connection Exhaustion

A Node.js API can look completely fine on its own while the real bottleneck sits at the database connection layer.

Picture your application getting hit with:

500 concurrent requests

and each of those requests trying to open its own fresh database connection.

At some point the database hits its configured connection ceiling.

From there, new requests start failing outright.

The API might respond with errors like:

Too many connections
Connection refused
Connection timeout

A connection pool exists precisely to stop every incoming request from spinning up a brand-new connection on its own.

Visually, it looks something like this:

┌── Connection 1
              ├── Connection 2
API ── Pool ──┼── Connection 3 ── Database
              ├── Connection 4
              └── Connection 5

The pool acts as a gatekeeper, capping how many connections can be open to the database at any given moment.

But sizing that pool correctly matters just as much as having one.

Configure the pool deliberately.

Set it too small:

Requests
   ↓
Waiting for connections
   ↓
High latency

Set it too large:

Application
   ↓
Too many connections
   ↓
Database overloaded

The right number depends on how much load your database can actually handle, how concurrent your application is, what your queries look like, and how your infrastructure is deployed.

Whatever you do, don't respond to connection errors by simply cranking the max pool size higher and higher without limit.

6. Eliminate N+1 Queries

Another frequent way teams accidentally hammer their database is the N+1 query pattern.

Picture an endpoint that returns a list of posts:

const posts = await Post.findAll();


for (const post of posts) {
  post.author = await User.findByPk(post.authorId);
}

With 1,000 posts in the table, this turns into:

1 query → retrieve posts

1,000 queries → retrieve authors

Total: 1,001 queries

With ten posts, the endpoint feels instant and nobody notices anything wrong.

Then the dataset grows.

And that same endpoint becomes painfully slow.

Eventually it can eat up enough database capacity to drag down unrelated endpoints too.

A few common fixes:

JOINs

SELECT
  posts.id,
  posts.title,
  users.name
FROM posts
JOIN users
  ON users.id = posts.author_id;

ORM eager loading

Most ORMs give you a way to pull related records upfront in a single controlled operation instead of one query per row.

Batch queries

Rather than firing off requests one at a time like:

Get user 1
Get user 2
Get user 3
...

fetch every needed user in one combined query.

While load testing, it's worth asking a simple diagnostic question:

How many separate database queries does this endpoint fire when it returns 10,000 records?

If the honest answer is:

"10,001."

that's a strong signal something needs fixing.

7. Add Pagination Before the Database Gets Huge

This snippet is a ticking time bomb:

const users = await User.findAll();

res.json(users);

It behaves beautifully in development, where the table holds:

37 users

Fast forward six months and that table now holds:

2,400,000 users

That one unchanged endpoint can now:

  • Pull millions of rows from the database
  • Eat up database resources
  • Eat up Node.js process memory
  • Serialize a huge JSON payload
  • Burn through bandwidth
  • Push response times way up
  • Risk hitting a timeout

The fix is pagination.

For instance, accept a request shaped like:

GET /users?page=1&limit=50

and handle it like this:

const limit = Math.min(
  Number(req.query.limit) || 50,
  100
);

const page = Number(req.query.page) || 1;

const offset = (page - 1) * limit;

Just as important is enforcing a ceiling on what clients are allowed to ask for.

Don't let a client send:

?limit=100000000

and force your API to try to execute a query of that size.

8. Stop Running Independent Operations Sequentially

Say an endpoint needs to gather three pieces of data:

User
Settings
Notifications

A naive implementation might look like:

const user = await getUser();
const settings = await getSettings();
const notifications = await getNotifications();

If each call takes 200 ms, waiting for them one after another adds up to roughly:

200 + 200 + 200 = 600 ms

When these calls don't depend on one another, run them concurrently instead:

const [
  user,
  settings,
  notifications
] = await Promise.all([
  getUser(),
  getSettings(),
  getNotifications()
]);

Now all three requests happen at the same time.

The total time drops close to whichever single call is slowest:

max(200, 200, 200) ≈ 200 ms

This isn't only about shaving milliseconds off a response.

At scale, extra latency per request means requests hold onto server resources for longer than necessary.

Requests that finish faster free up resources faster too.

That said, don't reach for Promise.all automatically in every case.

If one call genuinely needs the result of another, as in:

const user = await getUser();
const orders = await getOrders(user.id);

then those calls have to stay sequential by nature.

9. Add Graceful Shutdown

One of the simplest ways to introduce needless downtime during a deploy is killing the process abruptly.

Consider this sequence:

User → POST /checkout
              ↓
       Processing payment
              ↓
         Deployment starts
              ↓
          Process killed

The user in that scenario might just see:

Connection reset

even though the underlying operation may have already partly gone through.

Graceful shutdown solves this by having the server stop accepting new connections while letting in-flight requests finish naturally.

A minimal version looks like this:

const server = app.listen(PORT);

process.on("SIGTERM", () => {
  console.log("SIGTERM received");

server.close(() => {
    console.log("HTTP server closed");

    process.exit(0);
  });
});

In a production system, shutting down gracefully often also means closing:

  • Database connection pools
  • Redis connections
  • Message queue consumers
  • Background worker processes
  • Open WebSocket connections
  • Any other held resources

The overall goal boils down to:

Stop receiving new work
        ↓
Finish current work
        ↓
Close resources
        ↓
Exit

A deploy shouldn't feel like someone yanked the power cord out of the wall.

10. Use Health and Readiness Checks

It's entirely possible for a Node.js process to still be alive while the application behind it has effectively stopped working.

Picture this state:

Node.js process → Running
Database → DOWN
API → Practically unusable

If your load balancer's only signal is whether a TCP port responds, it might keep routing traffic to an instance that can't actually serve anything useful.

A readiness endpoint gives you a real signal to check:

app.get("/ready", async (req, res) => {
  try {
    await db.query("SELECT 1");

   res.status(200).json({
      status: "ready"
    });
  } catch (error) {
    res.status(503).json({
      status: "not_ready"
    });
  }
});

This lets your infrastructure tell apart two very different conditions:

Process is alive

versus:

Process is ready to serve traffic

That difference matters a lot when you're deploying new code or recovering from a failure.

11. Protect the Server From Traffic Spikes

Say your service comfortably handles:

100 requests/second

Then your product gets shared somewhere with a large audience.

All of a sudden you're facing:

2,000 requests/second

With no safeguards in place, that surge can overwhelm the API entirely.

Some common ways to absorb spikes:

  • Capping how many requests a client can make in a given window
  • Putting a reverse proxy in front of your application servers
  • Distributing incoming traffic across multiple instances
  • Caching responses so repeated work isn't redone
  • Serving static content through a CDN
  • Deferring work into a queue instead of processing it inline
  • Capping how many simultaneous database connections are allowed
  • Scaling instance count automatically as demand grows
  • Cutting off requests that take too long to complete

You don't necessarily need every item on this list.

But any production system should have a clear answer to the question:

What happens if traffic suddenly grows tenfold?

12. Move Expensive Work Out of the HTTP Request

Take an endpoint that creates a user and fires off a welcome email in the same request:

await createUser();
await sendWelcomeEmail();
res.json({ success: true });

If the email service takes three seconds to respond, that's three seconds the user spends waiting.

Even worse, a failure in the email step can cause the whole request to fail, even though the user account was created successfully.

A queue-based design avoids this coupling:

HTTP Request
     ↓
Create User
     ↓
Return response
     ↓
Queue email job
     ↓
Background Worker
     ↓
Send Email

The API no longer has to sit around waiting on slow background work to finish.

This pattern is especially useful in situations such as:

  • Dispatching confirmation or welcome emails
  • Building reports on demand
  • Resizing or transforming uploaded images
  • Encoding or transcoding video
  • Pushing out push notifications
  • Producing bulk data exports
  • Handling incoming webhook payloads
  • Running heavy numerical or analytical computations

As a rule, the HTTP request handler should do only the minimum amount of work needed to produce a response.

13. Make Background Jobs Retry Safely

Pushing work into a queue introduces a new question:

What happens when the worker itself fails partway through?

Consider this flow:

Worker receives payment notification
        ↓
Starts processing
        ↓
Crashes

The queue system will likely retry the job automatically.

That's generally a good thing.

But if the job isn't idempotent, retrying it can trigger the same side effect a second time.

For instance:

Send payment
Send payment again

or:

Create order
Create duplicate order

Because of this, background jobs need to be built with:

  • Retries
  • Backoff
  • Dead-letter handling
  • Idempotency
  • Maximum retry counts
  • Monitoring

Without idempotency, a retry mechanism can turn a single failure into several unwanted side effects.

14. Stop Logging Sensitive Information

While chasing down a bug in production, it's tempting to log everything you can get your hands on.

A common example:

console.log(req.body);

That request body could easily include things like:

Password
Access token
Credit-card information
Personal information

Once that happens, your logging system is now storing sensitive data.

A safer approach is to log only the fields you actually need:

logger.info({
  userId,
  requestId,
  endpoint: req.path
}, "Login attempt");

Don't fall into the trap of thinking:

"It's only in the logs."

Logs frequently persist for weeks, months, or even longer, and they often get copied into places like:

  • Cloud logging systems
  • Monitoring platforms
  • SIEM systems
  • Backup storage
  • Developer dashboards

Debugging a production issue shouldn't end up creating a separate security incident.

15. Add Request IDs Before You Need Them

When something breaks in production, the failure often spans several systems:

Client
  ↓
Nginx
  ↓
Node.js API
  ↓
Redis
  ↓
Database
  ↓
Payment API

Without some way to tie these events together, reconstructing what happened can be a slow, painful process.

The fix is to generate a request ID early:

request_id = 7f4e9c21

and then attach it to every relevant log entry or response:

API log
Database-related log
Payment log
Error response

For example:

{
  "requestId": "7f4e9c21",
  "method": "POST",
  "path": "/orders",
  "status": 500
}

With this in place, you can trace one request as it moves through your entire system.

So when a customer reports:

"My order failed."

you can simply ask for their request ID instead of digging through millions of log lines trying to match timestamps.

16. Prevent Duplicate Requests From Creating Duplicate Data

Network hiccups happen all the time, and clients need to handle them.

Imagine this sequence:

Client
  ↓
POST /orders
  ↓
Server creates order
  ↓
Response lost

The client has no way of knowing whether the request actually succeeded.

So, naturally, it retries:

POST /orders
  ↓
Server creates another order

The result:

Order #1001 → original request
Order #1002 → retry

This is exactly the scenario idempotency keys are meant to solve.

The client attaches a header like:

Idempotency-Key: 5e7b2d91-...

The server remembers the outcome tied to that key. If the same key shows up again, it just returns the original result instead of creating a second resource.

This matters most for operations like:

Payments
Orders
Subscriptions
Account creation
Resource provisioning

Any production API should design for the assumption that clients will retry requests, sometimes more than once.

17. Use Transactions When Data Must Stay Consistent

Think about a sequence like this:

Create order
Decrease inventory
Create payment record

Now imagine one of these steps fails partway through:

Create order → SUCCESS
Decrease inventory → SUCCESS
Create payment record → FAIL

Your database is now left holding a half-completed operation.

Whenever several database writes need to succeed as a unit, wrap them in a transaction.

The idea looks something like this:

await db.transaction(async (transaction) => {
  const order = await createOrder(transaction);

  await decreaseInventory(transaction);

  await createPaymentRecord(transaction);
});

And if any step throws an error:

Rollback

everything gets rolled back, rather than leaving inconsistent records behind.

Transactions won't fix every problem you run into in a distributed system, but within a single database they are one of the most reliable tools for keeping data consistent.

18. Don't Let One Slow Query Take Down the Database

A single badly written query can eat up a disproportionate amount of database capacity.

Take this example:

SELECT *
FROM orders
WHERE customer_id = ?
ORDER BY created_at DESC;

If customer_id has no index and the table holds millions of rows, this query can force the database to do far more work than it should.

Use

EXPLAIN

to inspect queries that matter.

Watch for warning signs like:

Full table scan
Huge number of rows examined
Unexpected joins
Poor index usage
Expensive sorting

Then base your optimization on what the evidence actually shows.

Don't rely on guesswork.

A query that returns in 50 milliseconds locally can take several seconds once it's running against production-scale data.

19. Set Resource Limits

A production server needs boundaries.

Without them, a single malicious or malformed request, or one runaway process, can consume all the resources available to it.

Think about setting limits on things like:

Request body size
File upload size
Database connections
HTTP request duration
Cache size
Queue size
Worker concurrency
Pagination size
Memory usage

For instance:

app.use(express.json({
  limit: "1mb"
}));

and:

const limit = Math.min(
  Number(req.query.limit) || 50,
  100
);

There's no universal number that fits every case.

But the underlying principle always applies.

Every resource should have a sensible cap.

Leaving something unbounded is rarely a sound choice in production.

20. Monitor Before the Incident, Not After

You won't be able to prevent every outage that ever happens.

What you can control is how quickly you notice one and how fast you recover.

At minimum, keep an eye on:

HTTP 5xx rate
Request latency
CPU
Memory
Database connections
Database latency
Event-loop health
Queue depth
External API failures
Restart count

For example:

09:00 → 0.2% errors
09:10 → 0.3%
09:20 → 0.5%
09:30 → 4.8% 🚨

If nobody is watching these numbers, you may only learn about a problem once users start complaining.

Solid alerting changes the story from:

"Customers are telling us the API is down."

to:

"Our 5xx rate crossed the threshold five minutes ago."

That gap in response time makes a real difference.

The Pattern Behind Most of These Fixes

Looking back across all these fixes, a common thread emerges.

Dependable Node.js systems don't operate on the assumption that everything will go right.

Instead, they're built assuming that:

Requests will fail.
Databases will become slow.
Networks will timeout.
Clients will retry.
Dependencies will go offline.
Traffic will spike.
Deployments will restart processes.
Developers will make mistakes.

and defenses are put in place around each of those assumptions.

The resulting architecture tends to look something like this:

                    ┌───────────────┐
                    │     Client    │
                    └───────┬───────┘
                            │
                            ▼
                    ┌───────────────┐
                    │ Reverse Proxy │
                    │ Rate Limiting │
                    └───────┬───────┘
                            │
                            ▼
                  ┌───────────────────┐
                  │    Node.js API    │
                  │                   │
                  │ Validation        │
                  │ Timeouts          │
                  │ Error Handling    │
                  │ Request IDs       │
                  └───────┬───────────┘
                          │
             ┌────────────┼────────────┐
             ▼            ▼            ▼
        ┌────────┐   ┌────────┐   ┌─────────┐
        │Database│   │ Redis  │   │ Queue   │
        └────────┘   └────────┘   └────┬────┘
                                      │
                                      ▼
                               ┌──────────────┐
                               │Worker / Jobs │
                               └──────────────┘

Every layer has a clear job.

Every external dependency has a defined failure strategy.

Every costly operation is bounded by some limit.

Every meaningful action can be observed and measured.

That combination is what gives a system its resilience.

The Most Important Lesson

The biggest gains in reliability don't always come from adding more infrastructure.

Sometimes the fix is a single changed line of code.

Sometimes it's adding a missing index.

Sometimes it's wrapping an external API call in a timeout.

Sometimes it's turning 1,001 database queries into two.

Sometimes it's wrapping a set of writes in a transaction.

Sometimes it's just making sure the process shuts down cleanly.

What separates a fragile API from a dependable one usually comes down to a series of small engineering choices.

No single one of them looks particularly remarkable on its own.

But taken together, they're what stop an ordinary day in production from turning into an emergency.

So the next time your Node.js API is behaving exactly as expected in development, don't stop at asking:

"Does it work?"

Ask instead:

"What happens when it doesn't?"

That question marks the real starting point of production reliability.

And the best kind of downtime incident is the one your users never notice, because you caught and fixed the failure before it turned into an outage.