Fix MongoPoolClosedError: Attempted to check out a connection from closed connection pool

intermediate๐Ÿƒ MongoDB2026-05-10| Node.js 16+, MongoDB Node.js Driver 4.x/5.x, Mongoose 6.x/7.x, running on Linux/macOS/Windows

Error Message

MongoPoolClosedError: Attempted to check out a connection from closed connection pool
#mongodb#connection-pool#mongoose#nodejs#graceful-shutdown

What's happening

Somewhere in your code, client.close() or mongoose.disconnect() ran. That drained and shut down the connection pool. The problem? A pending request, an async callback, or a background job still tried to run a MongoDB operation afterward โ€” and hit nothing but a closed door.

The full error looks like this:

MongoPoolClosedError: Attempted to check out a connection from closed connection pool
    at ConnectionPool.checkOut (/node_modules/mongodb/lib/cmap/connection_pool.js:...)
    at Server.command (/node_modules/mongodb/lib/sdam/server.js:...)

This isn't a network blip or a replica set failover. The pool was deliberately closed โ€” usually during app shutdown โ€” while in-flight operations were still running. Classic race condition.

Step 1: Find where the pool closes too early

First, confirm the timing with a quick log:

// Native driver
client.on('connectionPoolClosed', () => {
  console.log('[MongoDB] Connection pool closed at', new Date().toISOString());
});

// Mongoose
mongoose.connection.on('disconnected', () => {
  console.log('[Mongoose] Disconnected at', new Date().toISOString());
});

Trigger a shutdown (SIGTERM, SIGINT, or however you stop the process) and compare timestamps. If the pool closes before your last DB operation, you have a race. In most apps, the gap is under 200ms โ€” enough to let a slow findOne() slip through.

Step 2: Check your shutdown handler

Nine times out of ten, the culprit looks like this:

// BAD โ€” closes the pool immediately, racing with in-flight requests
process.on('SIGTERM', () => {
  mongoose.disconnect(); // no await โ€” fire and forget
  server.close();
  process.exit(0);
});

Any request already inside an async pipeline โ€” a query that hasn't been awaited yet โ€” will hit the closed pool and throw MongoPoolClosedError. Under load, this happens on nearly every deploy.

Step 3: Fix the shutdown order

Stop accepting new work first. Let existing work finish. Close MongoDB last.

// GOOD โ€” ordered graceful shutdown
async function shutdown(signal) {
  console.log(`Received ${signal}, shutting down...`);

  // 1. Stop accepting new HTTP requests
  await new Promise((resolve) => server.close(resolve));
  console.log('HTTP server closed');

  // 2. All HTTP handlers have finished โ€” safe to disconnect
  await mongoose.disconnect();
  console.log('MongoDB disconnected');

  process.exit(0);
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT',  () => shutdown('SIGINT'));

server.close() stops new connections from coming in, but lets active requests complete. Only after its callback fires do you disconnect MongoDB. No in-flight request can reach a closed pool.

Step 4: Add a timeout guard

One hung request can block shutdown forever. A 10-second hard limit prevents that:

async function shutdown(signal) {
  const TIMEOUT_MS = 10_000; // bail out after 10 seconds

  const forceExit = setTimeout(() => {
    console.error('Shutdown timed out, forcing exit');
    process.exit(1);
  }, TIMEOUT_MS);

  forceExit.unref(); // don't keep the event loop alive for this timer

  try {
    await new Promise((resolve) => server.close(resolve));
    await mongoose.disconnect();
    console.log('Graceful shutdown complete');
    process.exit(0);
  } catch (err) {
    console.error('Error during shutdown:', err);
    process.exit(1);
  }
}

10 seconds covers the vast majority of slow queries. Adjust down to 5s for latency-sensitive services, or up to 30s if your jobs are genuinely long-running.

Step 5: Handle background jobs separately

Cron jobs, queue consumers, and setInterval loops are invisible to server.close(). They can fire MongoDB queries well after the HTTP server has stopped. Track them explicitly:

const activeJobs = new Set();

function runJob(fn) {
  const job = fn().finally(() => activeJobs.delete(job));
  activeJobs.add(job);
  return job;
}

async function shutdown(signal) {
  // Stop scheduling new jobs (clearInterval, queue.close(), etc.)

  // Drain any currently-running jobs
  if (activeJobs.size > 0) {
    console.log(`Waiting for ${activeJobs.size} background job(s)...`);
    await Promise.allSettled([...activeJobs]);
  }

  await new Promise((resolve) => server.close(resolve));
  await mongoose.disconnect();
  process.exit(0);
}

Wrapping jobs in runJob() gives you a live count. You'll see log lines like Waiting for 3 background job(s)... instead of silent crashes.

Step 6: Native MongoDB driver

Same rule applies. Call client.close() last:

const { MongoClient } = require('mongodb');
const client = new MongoClient(process.env.MONGODB_URI);

async function shutdown() {
  await new Promise((resolve) => server.close(resolve));
  await client.close();
  console.log('MongoDB client closed');
  process.exit(0);
}

Quick note on the force boolean: client.close(false) (the default) lets the pool drain gracefully. client.close(true) closes immediately โ€” which can still throw this error for truly in-flight ops. Default is almost always what you want.

Verification

Restart the app, send some traffic, then trigger shutdown while requests are in progress:

# Terminal 1 โ€” hammer it with requests
while true; do curl -s http://localhost:3000/api/data > /dev/null; done

# Terminal 2 โ€” trigger shutdown after 5 seconds
sleep 5 && kill -SIGTERM $(pgrep -f 'node app.js')

Watch the logs. You want to see: HTTP server closes โ†’ MongoDB disconnects โ†’ exit code 0. No MongoPoolClosedError. If that's what you get, you're done.

Lessons learned

  • Order matters more than you think. MongoDB should always be the last thing you close โ€” after the HTTP server and job queues have drained.
  • Never fire-and-forget disconnect(). Always await it. Otherwise you have no idea when (or if) the pool actually closed.
  • Background jobs will bite you. Any setInterval or queue consumer touching MongoDB needs to be stopped and drained before you close the pool.
  • Timeouts save deployments. A graceful shutdown that can hang forever is worse than one that times out โ€” at least the process manager (systemd, Docker, PM2) can restart it.

Related Error Notes