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(). Alwaysawaitit. Otherwise you have no idea when (or if) the pool actually closed. - Background jobs will bite you. Any
setIntervalor 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.

