Diagram showing MongoDB connection caching architecture in AWS Lambda with global cache layer, promise deduplication, and MongoDB Atlas connection pool

MongoDB Connection Management in AWS Lambda

MongoDB connection management in AWS Lambda looks fine in a tutorial and falls apart under production traffic. Lambda containers appear and vanish by the hundreds; MongoDB expects long-lived TCP sockets. That mismatch is how Atlas connection graphs go vertical.

Here’s the pattern I ship: no polling loops, no duplicate listeners, and no leaked connections on Atlas.

Why MongoDB connection management in AWS Lambda is different

If you’ve ever looked at your MongoDB Atlas connection graph and seen it spike to hundreds of open connections from a handful of Lambda functions, you’ve already hit this problem. The root cause is simple: Lambda’s execution model was never designed with persistent TCP connections in mind.

In a traditional Node.js server, you call mongoose.connect() once at startup and reuse that connection for the lifetime of the process. The process runs for hours, days, or indefinitely. One server, one connection pool — predictable and efficient.

Lambda doesn’t work like that. Every cold start spins up a new execution environment. Your module-level code runs again. If you call mongoose.connect() at the top of your handler or at module import time without caching, you get a fresh connection on every cold start. Under load, with dozens of concurrent cold starts, you can exhaust Atlas’s connection limits in seconds.

How Lambda warm starts work

Lambda freezes the execution environment between invocations and thaws it for subsequent requests — this is a “warm start.” Module-level variables persist across warm invocations within the same container. This is the mechanism we exploit for connection caching. The global object persists across warm starts, which is exactly why global.mongooseCache is the right place to store the connection.

The goal is: one MongoDB connection per Lambda container, reused across all warm invocations, with clean recovery when that connection drops.

Version 1: The naive approach and its flaws

Most tutorials start here, and most production outages start here too:

connectDB.js — naive versionjs

// ❌ Don't do this
async function connectDB() {
  await mongoose.connect(process.env.MONGODB_URI);
}

Every Lambda invocation — cold or warm — calls mongoose.connect(). Mongoose is tolerant enough that calling connect on an already-connected instance won’t crash, but it does trigger reconnection logic and creates unnecessary overhead. More critically, if two concurrent invocations arrive during a cold start, they both race to create a new connection simultaneously.

A common intermediate fix is to check mongoose.connection.readyState:

connectDB.js — with readyState guardjs

async function connectDB() {
  if (mongoose.connection.readyState === 1) {
    return;
  }
  await mongoose.connect(process.env.MONGODB_URI);
}

This is better. Warm invocations return immediately. But there’s still a race condition: if two cold-start invocations hit this function concurrently, both read readyState === 0, both proceed to mongoose.connect(), and you end up with two parallel connection attempts. This is the infamous “thundering herd” problem in miniature.

Some developers solve the race with an isConnecting boolean flag and a polling interval:

connectDB.js — polling anti-patternjs

let isConnecting = false;

if (isConnecting) {
  return new Promise((resolve) => {
    // ❌ This interval runs forever if connection never resolves
    const check = setInterval(() => {
      if (mongoose.connection.readyState === 1) {
        clearInterval(check);
        resolve(cachedConnection);
      }
    }, 100);
  });
}

Memory leak

If the connection attempt fails or stalls, readyState never reaches 1. The interval runs indefinitely, holding a reference that prevents garbage collection and consumes CPU every 100ms until the Lambda container is recycled. In practice this is a silent memory and CPU leak.

The fix is to not poll at all. JavaScript is single-threaded — promises are the right primitive for “wait until this async thing completes.”

Version 2: Promise-based deduplication

Instead of a boolean flag and a polling loop, store the in-flight promise itself. All concurrent callers await the same promise object. When it resolves, everyone gets the connection. When it rejects, everyone gets the error — and the promise slot is cleared so the next call can try again.

connectDB.js — promise deduplicationjs

let cachedConn = null;
let connectionPromise = null;

async function connectDB() {
  if (cachedConn && mongoose.connection.readyState === 1) {
    return cachedConn;
  }

  // ✅ All concurrent callers share one in-flight promise
  if (!connectionPromise) {
    connectionPromise = mongoose.connect(uri, options)
      .then((instance) => { cachedConn = instance; return instance; })
      .catch((err) => { connectionPromise = null; throw err; });
  }

  return connectionPromise;
}

This is significantly better. No polling, no leaking intervals. But module-level variables in Lambda are scoped to the execution environment. When a cold start creates a new environment, cachedConn and connectionPromise are both null — correct behavior.

However, there’s a subtlety: the global object in Node.js persists across module re-evaluations within the same process. In frameworks like Next.js (which uses Webpack and re-evaluates modules), module-level variables reset on hot reload while global does not. For Lambda, the practical difference is small, but using global is the more defensive choice — and it’s become the established convention for exactly this reason.

Version 3: Handling disconnect events

The promise cache solves the connection race. But what happens when an established connection drops mid-flight — say Atlas restarts a node, or your Lambda container has been idle long enough that the TCP socket timed out?

Without event handling, here’s what happens:

  1. Container is warm, cachedConn is set, readyState === 1
  2. Atlas drops the connection (maintenance, network blip, idle timeout)
  3. A new Lambda invocation arrives
  4. Your guard checks cachedConn && readyState === 1 — but now readyState === 0
  5. It falls through and creates a new connection — this part is fine

Actually, in this scenario, the readyState !== 1 guard would catch it and fall through to reconnect. But there’s a narrow timing window: the disconnect event fires asynchronously, and there’s a brief moment where cachedConn is still set while readyState is transitioning. The stale-state guard we add explicitly handles this:

Explicit stale-state guardjs

// If conn exists but readyState says otherwise — stale connection
if (cached.conn && mongoose.connection.readyState !== 1) {
  cached.conn = null;
  cached.promise = null;
}

To be clear about what’s doing the real work here: readyState is the primary protection. The Mongoose driver handles its own internal recovery depending on the failure type — your event handlers don’t trigger a reconnect. What they do is clear your app-level cache pointers so the next connectDB() call can either reuse a recovered driver state or create a fresh connection safely, without returning a stale object from the cache:

Disconnect event handlersjs

mongoose.connection.on("disconnected", () => {
  console.warn("MongoDB disconnected. Clearing cache.");
  global.mongooseCache.conn = null;
  global.mongooseCache.promise = null;
});

mongoose.connection.on("error", (err) => {
  console.error("MongoDB connection error:", err.message);
  global.mongooseCache.conn = null;
  global.mongooseCache.promise = null;
});

These listeners need to be registered exactly once per container lifetime — not once per function invocation. Registering them inside connectDB() would add a new listener on every warm call, eventually triggering Node’s MaxListenersExceededWarning and degrading performance.

The listenersAttached boolean on the cache object guards against this:

Listener deduplication guardjs

if (!cached.listenersAttached) {
  // attach listeners...
  cached.listenersAttached = true;
}

An alternative is to place the listener registration inside the if (!global.mongooseCache) initialization block, so it only runs on the very first cold start. Both approaches are equivalent — the initialization block approach is slightly cleaner since it avoids the separate boolean.

The pattern I actually ship

Here’s the complete implementation incorporating everything discussed:

connectDB.js — production-readyjs

const mongoose = require("mongoose");

// Initialize global cache exactly once per container lifetime
if (!global.mongooseCache) {
  global.mongooseCache = {
    conn: null,
    promise: null,
  };

  // Register event listeners once — never inside connectDB()
  mongoose.connection.on("disconnected", () => {
    console.warn("MongoDB disconnected. Clearing cache.");
    global.mongooseCache.conn = null;
    global.mongooseCache.promise = null;
  });

  mongoose.connection.on("error", (err) => {
    console.error("MongoDB connection error:", err.message);
    global.mongooseCache.conn = null;
    global.mongooseCache.promise = null;
  });
}

const cached = global.mongooseCache;

async function connectDB() {
  // Fast path: fully connected, return immediately
  if (cached.conn && mongoose.connection.readyState === 1) {
    return cached.conn;
  }

  // Stale-state guard: conn set but socket is dead
  if (cached.conn && mongoose.connection.readyState !== 1) {
    cached.conn = null;
    cached.promise = null;
  }

  if (!process.env.MONGODB_URI) {
    throw new Error("MONGODB_URI environment variable is missing");
  }

  // Build or reuse the in-flight connection promise
  if (!cached.promise) {
    cached.promise = mongoose
      .connect(process.env.MONGODB_URI, {
        maxPoolSize: 1,       // one socket per Lambda container
        minPoolSize: 0,       // scale to zero when idle
        serverSelectionTimeoutMS: 5000,
        socketTimeoutMS: 45000,
        bufferCommands: false, // fail fast instead of queuing
      })
      .then((mongooseInstance) => {
        cached.conn = mongooseInstance;
        console.log("MongoDB connected");
        return mongooseInstance;
      })
      .catch((err) => {
        cached.conn = null;
        cached.promise = null;
        throw err;
      });
  }

  // Return the promise — resolves instantly for concurrent callers
  return cached.promise;
}

module.exports = connectDB;

Line-by-line breakdown

Here’s what each section does and why the order matters.

The global cache initialization

Code pathWhen it runsWhat it does
if (!global.mongooseCache)First cold start onlyInitializes cache and registers event listeners
const cached = global.mongooseCacheEvery module evaluationGets a reference to the shared cache object

The global object persists for the lifetime of the Node.js process, across module re-evaluations. Subsequent calls to require('./connectDB') skip the initialization block entirely.

The connection state machine

Mongoose’s readyState has four values:

readyStateMeaningOur action
0DisconnectedReconnect
1ConnectedReturn cached.conn
2ConnectingReturn cached.promise
3DisconnectingClear cache, reconnect

The fast path returns immediately when readyState === 1. For state 2 (connecting), cached.promise is already set from the concurrent call that initiated the connection — so we fall through to return cached.promise, which is still pending and will resolve when the connection completes. States 0 and 3 hit the stale-state guard and trigger a fresh connection.

Why bufferCommands: false matters

By default, Mongoose buffers queries when the connection is not ready. In a traditional server this is useful — queries fire once the connection establishes. In Lambda it’s a footgun: a query issued against a disconnected connection will silently queue and wait, blocking your handler until socketTimeoutMS expires. With bufferCommands: false, that query throws immediately, surfacing the connection problem clearly rather than hanging your function.

The event loop problem: callbackWaitsForEmptyEventLoop

There’s a Lambda-specific behavior that catches many developers off guard, and most MongoDB guides either bury it or skip it entirely. By default, Lambda waits for the Node.js event loop to empty before it considers an invocation complete and freezes the container. An open MongoDB connection registers as a pending async operation — which means the event loop never fully empties.

The result: your handler resolves, but Lambda doesn’t freeze the container. It hangs waiting for the event loop to drain, adding latency on every invocation while Lambda times out waiting for a socket that’s intentionally being kept open.

The fix is a single line in your handler:

Lambda handler — event loop fixjs

exports.handler = async (event, context) => {
  // Tell Lambda not to wait for the event loop to drain
  context.callbackWaitsForEmptyEventLoop = false;

  await connectDB();
  // ... your handler logic
};

Setting this to false tells Lambda: once the handler’s returned promise resolves, freeze the container immediately — don’t wait for open sockets or other async handles to close. The MongoDB connection stays alive in the frozen container, ready for the next warm invocation.

Is this still required in 2026?

Historically, almost every MongoDB + Lambda guide included this line because Node.js 8/10/12 runtimes would reliably hang waiting for open MongoDB sockets. With modern Lambda Node.js 18/20 runtimes, async/await handlers, MongoDB driver 5.x/6.x, and Mongoose 7.x/8.x, many applications work correctly without it. It is no longer a hard requirement — but it remains a low-risk defensive option. If you observe Lambda duration running higher than expected and suspect open handles or container-freeze issues, adding it is the first thing to try.

Lambda concurrency and Atlas connection limits

Here’s the issue that can bite you even when your connection caching code is completely correct: Atlas connection exhaustion from Lambda concurrency, not from bad caching.

The connection math is straightforward:

Atlas connection count formulatext

total Atlas connections = concurrent Lambda containers × maxPoolSize

Example:
  500 concurrent invocations
  × maxPoolSize: 1
  = 500 connections

  500 concurrent invocations
  × maxPoolSize: 3
  = 1,500 connections

Each warm Lambda container holds its own connection pool. Your caching code ensures one connection per container — but under sustained load, AWS can spin up hundreds of parallel containers, each holding their own connection. The caching is working perfectly; you’re just running out of Atlas capacity.

Atlas tierMax connectionsMax Lambda containers (pool=1)
M0 (free)500~500
M101,500~1,500
M203,000~3,000
M305,000~5,000

The lever to pull here is Reserved Concurrency on your Lambda function. It caps the maximum number of concurrent containers AWS will spin up for that function:

AWS Console / CDK / Serverless Frameworktext

# Serverless Framework
functions:
  api:
    reservedConcurrency: 100   # max 100 containers = max 100 connections

# CDK
fn.addReservedConcurrentExecutions(100);

Reserved concurrency is a hard cap, not a target

Setting reserved concurrency to 100 means your function will never scale beyond 100 concurrent invocations — which also means it will throttle (return a 429) if demand exceeds that. Size it based on your Atlas tier’s connection limit minus headroom for other services. A practical formula: reservedConcurrency = (Atlas max connections × 0.8) / maxPoolSize. The 0.8 leaves room for Atlas admin connections and other services sharing the cluster.

If you need higher throughput than your Atlas tier supports, the alternatives are: upgrade the Atlas tier; put a small API service in front of Atlas (for example Express on a container, or a single Lambda behind API Gateway) so that a controlled number of long-lived driver connections is shared by many callers. Do not build on the Atlas Data API: MongoDB ended it on 30 September 2025; introduce a proxy or connection pooling layer between Lambda and Atlas; or move select workloads to a connection-per-request service like DynamoDB.

Pool sizing and performance tuning

With the concurrency math in mind, maxPoolSize becomes a more nuanced decision than “just set it to 1.”

maxPoolSize: 1 is correct for most Lambda handlers

Lambda handles concurrency by spawning parallel containers, not by running parallel requests inside a single container. Each container processes one request at a time. For a handler that runs a single query, one socket is all you need — a larger pool just holds idle sockets open, consuming Atlas connection slots for no benefit.

But “one request at a time” doesn’t mean “one query at a time.” If your handler uses Promise.all to run parallel queries, those queries may contend for the same connection and reduce concurrency. Modern MongoDB drivers can pipeline work, so it’s not always a strict serialization — but throughput is still constrained by pool size:

Parallel queries — pool size impactjs

// With maxPoolSize: 1, parallel queries may contend for the
// same connection and reduce effective concurrency
await Promise.all([
  User.findById(id),
  Post.find({ userId: id }),
  Notification.find({ userId: id }),
]);

// With maxPoolSize: 3, all three can execute with less contention
// Total time closer to max(query1, query2, query3)

Increasing maxPoolSize can improve throughput for workloads that execute multiple concurrent database operations per invocation. The tradeoff is more Atlas connections per container — which means your reserved concurrency limit needs to be lower to stay within the same connection budget.

ScenarioRecommended maxPoolSizeNote
Single query per handler1Default — lowest Atlas connection cost
2–3 parallel queries with Promise.all3Reduces handler duration; watch connection budget
Many parallel queries5Lower reserved concurrency accordingly

serverSelectionTimeoutMS: 5000 controls how long the driver waits to find an available MongoDB server on initial connection. If your Atlas cluster is unhealthy and this is too low, you’ll see false connection failures during maintenance windows. If it’s too high, cold starts will hang. 5 seconds is a good default for Lambda.

socketTimeoutMS: 45000 is how long to wait for a response on an individual socket operation. AWS Lambda’s maximum function timeout is 15 minutes, so 45 seconds is generous for most query patterns but still bounded.

Production checklist

Before you ship

  • ✅ context.callbackWaitsForEmptyEventLoop = false set in every handler that calls connectDB
  • ✅ Reserved Concurrency configured — (Atlas max connections × 0.8) / maxPoolSize
  • ✅ MONGODB_URI is in Lambda environment variables, not hardcoded
  • ✅ bufferCommands: false is set — no silent query queuing
  • ✅ maxPoolSize: 1 (or higher if doing parallel queries per invocation)
  • ✅ Disconnect and error event listeners are registered once, outside connectDB()
  • ✅ The error handler logs err.message — not silently swallowing errors
  • ✅ Atlas IP Access List includes your Lambda’s NAT Gateway / Elastic IP
  • ✅ Atlas cluster tier supports your connection count (M0 free tier = 500 connections max)
  • ✅ CloudWatch log retention is set — cold start connection logs should be visible

Monitoring what matters

On Atlas, watch the Connections metric in the Metrics tab. In a healthy Lambda setup, you should see connection count roughly track the number of warm Lambda containers — not spike unboundedly. If you see connections growing linearly under sustained load without leveling off, you likely have a caching bug and each invocation is creating a new connection.

In CloudWatch, the “MongoDB connected” log line (from the .then() callback) should only appear on cold starts. If it appears on every invocation, the cache isn’t working — the stale-state guard is probably over-clearing on every call.

A note on Next.js and other SSR frameworks

This pattern is especially important in Next.js API routes and server components running on Vercel or Lambda. Next.js’s Fast Refresh and Webpack module bundling can re-evaluate modules during development, which resets module-level variables but preserves global. The global.mongooseCache pattern was popularized by the Vercel team for exactly this reason. In production it behaves identically to Lambda — one container, one persistent process, global survives across requests.

What I actually check before calling it done:

The complete pattern: check readyState as your primary guard, handle stale connections explicitly, deduplicate in-flight connection attempts with a shared promise, register disconnect event listeners once at initialization, set callbackWaitsForEmptyEventLoop = false in every handler, and cap Lambda concurrency to stay within your Atlas connection budget. Get all six right and you’ll never debug a connection exhaustion incident at 2am.

FAQ

Why does MongoDB Atlas show hundreds of connections from a few Lambda functions?

Each concurrent Lambda container opens its own connection pool. Total Atlas connections = concurrent containers × maxPoolSize, so 300 parallel invocations with a pool of 1 means 300 connections — even with perfect connection caching. Cap it with Reserved Concurrency sized to your Atlas tier’s limit.

Do I still need callbackWaitsForEmptyEventLoop = false in 2026?

It’s no longer the hard requirement it was on Node.js 8–12 runtimes. With Node 18/20, async handlers and Mongoose 7/8, many apps run fine without it — but it remains a zero-risk defensive line, and it’s the first thing to add if your Lambda durations run longer than your actual handler work.

What should maxPoolSize be for MongoDB in AWS Lambda?

Start with 1 — each Lambda container handles one request at a time, so one socket usually suffices and keeps Atlas connection costs minimal. Raise it to 3–5 only if your handler runs multiple parallel queries with Promise.all, and lower your Reserved Concurrency correspondingly to stay inside the same connection budget.

Why is my Mongoose query hanging in Lambda instead of failing?

Almost certainly connection buffering: by default Mongoose silently queues queries issued while disconnected, and they wait until socketTimeoutMS expires. Set bufferCommands: false so a query against a dead connection throws immediately instead of hanging your function.

Can I just call mongoose.connect() in every Lambda invocation?

It won’t crash — Mongoose tolerates repeat connect calls — but it triggers reconnection overhead on every invocation, and concurrent cold starts will race to create parallel connections. Cache the connection promise on the global object instead: one connection per container, shared by all warm invocations.

Keep reading

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *