Ragib HasanDecember 202518 min read

Architecting for N-Tenants: Solving Database Connection Exhaustion in Node.js

How we scaled from 300 to N tenants without adding infrastructure by replacing eager connection initialization with a dynamic, lazy-loading connection manager.

Node.js
System Design
MongoDB
Multi-Tenancy
Microservices

TL;DR

We scaled from 300 to N tenants without adding infrastructure by replacing eager connection initialization with a dynamic, lazy-loading connection manager that uses promise caching and LRU-style eviction. Infrastructure costs became flat. We now pay for active usage, not total registered tenants.

The Wall We Hit

Scalability issues rarely show up on Day 1. They surface quietly, usually the moment growth becomes real.

For the B2B platform we manage, that moment arrived when we crossed roughly 300 active tenants on our SaaS ecosystem.

We use a Database-per-Tenant model for strict isolation. A deliberate architectural choice built around security, compliance, performance predictability, and operational clarity. But at this scale, the original design suddenly exposed a critical flaw:

We were creating one database connection pool per tenant at server startup.

At 300 tenants, that meant:

text
300 tenants × 10 pool connections  3,000 open sockets

Most of these tenants were idle, yet the server was paying a full-time penalty for them.

Connection Utilization at 300 Tenants

...
Active (>50%) Light (10-50%) Idle (<10%)

85% of connections sat idle, consuming resources for tenants that weren't active

Within days, we encountered:

  • EMFILE errors (too many open files)
  • 1.5GB+ RAM just for idle connections
  • 45+ second startup to initialize all connections
  • MongoDB connection storms
  • OOM kills
bash
# The errors that woke me up at 2 AM:
Error: EMFILE: too many open files
MongoServerSelectionError: connection pool exhausted

This wasn't a hosting problem. It was an architectural bottleneck that needed a systematic redesign.

The Hidden Cost of "Clean Isolation"

In the early architecture, we did what most Node.js/Mongoose projects do: initialize all tenant database connections at server startup.

The process was simple:

  1. Loop through all tenant configs
  2. Create one Mongoose connection per tenant
  3. Cache models per connection
  4. Start the server

Harmless with a dozen tenants. Questionable at 100. Catastrophic at 300.

The Connection Math

Understanding the resource consumption helps explain why this became critical:

File Descriptor Limits

Each MongoDB connection consumes a file descriptor. Linux defaults to 1,024 per process.

300 tenants × 10 connections = 3,000 file descriptors Default limit: 1,024 EMFILE errors guaranteed

Memory Overhead

Each connection maintains buffers, authentication state, and query queues.

Per connection: ~5 MB (buffers + state + TLS) 3,000 connections × 5 MB = 15 GB theoretical Actual (with pooling): ~1.5-2 GB for idle connections

TCP Keep-Alive Traffic

Idle connections aren't free. They maintain heartbeats.

MongoDB heartbeat: every 10 seconds 3,000 connections × 6 heartbeats/min = 18,000 packets/min Just for connections doing nothing.

Why Database-per-Tenant?

Before the solution, let me explain why we use database-per-tenant. This wasn't a light decision. It's a deliberate trade-off.

Zero-Risk Data Isolation

With database-per-tenant, there's no tenantId field to forget. The isolation is structural, not conditional.

javascript
// Shared collection: ONE bug can leak data
const products = await Product.find({ tenantId: req.tenantId });
// What if a developer forgets tenantId filter? 💀 LEAKS ALL DATA

// Database-per-tenant: Impossible to leak
const Product = req.db.model('Product');
const products = await Product.find({ category: 'electronics' });
// Only queries THIS tenant's database

Security & Compliance

  • SOC 2 Type II requiring physical data separation
  • GDPR Article 17: drop a database vs. surgical deletion
  • Industry regulations (healthcare, finance) requiring isolated storage

Noisy Neighbor Prevention

When Tenant A runs a massive aggregation pipeline in a shared DB, it locks collections, spikes CPU, and degrades all tenants. In separate databases, only Tenant A experiences the slowdown.

Operational Flexibility

  • Per-tenant backups: Restore one tenant without affecting others
  • Custom indexes: High-volume tenants can have specialized indexes
  • Migration flexibility: Move hot tenants to dedicated clusters
  • Clean offboarding: Drop the database. Done.

The Trade-Off

Database-per-tenant provides maximum isolation but introduces a scaling challenge: connection overhead grows linearly.

TenantsPool SizeConnectionsStatus
1010100✓ Fine
100101,000⚠ Noticeable
300103,000✗ Crash
1,000+1010,000+💀 Impossible

We kept DB-per-Tenant for one reason: isolation is non-negotiable.

The model was correct. The implementation needed to evolve.

The Architecture That Saved Us

To break the linear scaling problem, I designed a Dynamic, Lazy-Loading Connection Manager.

The mindset shift:

"Connect to every tenant on startup."

"Connect to tenants only when they're active."

Traditional: Eager Loading

Server Boot

300 connections created at startup

Dynamic: Lazy Loading

Server Boot
0 connections • Ready instantly
Request arrives → Connect on demand

Principle 1: Zero Connections at Boot

No connections at boot. A connection is created only when a request arrives for that tenant. If tenant A makes 2,000 requests and tenant B makes none, only tenant A keeps an active connection.

This single change alone cut our connection footprint by ~90%.

Principle 2: Promise Caching (Thundering Herd Prevention)

Lazy-loading introduces a new problem: if 50 requests for tenant X arrive simultaneously and no connection exists, you get 50 parallel connection attempts.

The Thundering Herd Problem

When multiple requests arrive for an uncached resource, they all try to create it simultaneously, causing resource exhaustion and duplicate work.

The fix: store the Promise of the connection, not just the connection itself.

  • First request triggers the connection handshake
  • Next 49 requests await the same Promise
  • Only one connection is ever created per tenant
javascript
// ❌ WRONG: Race condition
if (!this.connections.has(tenantId)) {
  const conn = await createConnection(uri); // 50 calls happen here!
  this.connections.set(tenantId, conn);
}

// ✅ CORRECT: Promise caching
if (!this.promises.has(tenantId)) {
  const promise = createConnection(uri);    // Promise created
  this.promises.set(tenantId, promise);     // Cached IMMEDIATELY
}
return await this.promises.get(tenantId);   // All requests share it

Principle 3: LRU-Style Eviction

Active tenants stay warm. Inactive tenants get cleaned up. Each connection tracks a lastUsedAt timestamp. A background loop evicts connections idle beyond the TTL (30 minutes in production).

Connection Lifecycle

📨
Requestx-tenant-id header
🔍
Check CacheConnection exists?
🔌
Create/ReusePromise caching
ExecuteQuery runs
⏱️
Update TTLReset lastUsedAt
Background eviction after 30min idle

Principle 4: Decoupling Tenant Count from Load

Total tenants no longer affect server cost. Only active concurrent tenants matter. This is what makes the system scale to N.

The Implementation

Here's the core of the TenantConnectionManager. I've included the critical parts with inline comments explaining the design decisions:

TenantConnectionManager.js
class TenantConnectionManager {
  constructor() {
    // Active connections: tenantId -> { conn, lastUsedAt }
    this.connections = new Map();
    
    // In-flight promises: tenantId -> Promise<connection>
    // This is the thundering herd protection
    this.connectionPromises = new Map();
    
    // Configuration from environment
    this.config = {
      poolSize: parseInt(process.env.DB_POOL_SIZE) || 10,
      minPoolSize: parseInt(process.env.DB_MIN_POOL_SIZE) || 2,
      ttlMs: parseInt(process.env.DB_CONN_TTL_MS) || 30 * 60 * 1000,
      cleanupIntervalMs: parseInt(process.env.DB_CLEANUP_INTERVAL_MS) || 60000,
    };
    
    // Start the background eviction loop
    this.startCleanupLoop();
  }

  async getConnection(tenantId) {
    // 1. Check for existing healthy connection
    const existing = this.connections.get(tenantId);
    if (existing?.conn?.readyState === 1) {
      existing.lastUsedAt = Date.now();  // Touch for LRU
      return existing.conn;
    }

    // 2. Check for in-flight connection (thundering herd protection)
    if (this.connectionPromises.has(tenantId)) {
      return this.connectionPromises.get(tenantId);
    }

    // 3. Create new connection, cache the Promise immediately
    const connectionPromise = this.createConnection(tenantId);
    this.connectionPromises.set(tenantId, connectionPromise);

    try {
      const conn = await connectionPromise;
      this.connections.set(tenantId, {
        conn,
        lastUsedAt: Date.now()
      });
      return conn;
    } finally {
      // Always clean up the promise cache
      this.connectionPromises.delete(tenantId);
    }
  }
}

The Eviction Loop

javascript
startCleanupLoop() {
  setInterval(() => {
    const now = Date.now();
    
    for (const [tenantId, entry] of this.connections.entries()) {
      const idleTime = now - entry.lastUsedAt;
      
      if (idleTime > this.config.ttlMs) {
        // Close the connection gracefully
        entry.conn.close().catch(err => {
          console.error(`Error closing connection for ${tenantId}:`, err);
        });
        
        this.connections.delete(tenantId);
        console.log(`[Evicted] ${tenantId} after ${Math.round(idleTime/1000)}s idle`);
      }
    }
  }, this.config.cleanupIntervalMs);
}

The Middleware Layer

The connection manager integrates with Express through tenant middleware:

middleware/tenantMiddleware.js
// middleware/tenantMiddleware.js
export const tenantMiddleware = async (req, res, next) => {
  const tenantId = req.headers['x-tenant-id'];

  if (!tenantId) {
    return res.status(400).json({
      success: false,
      error: 'Tenant ID is required',
      code: 'MISSING_TENANT_ID',
    });
  }

  try {
    // Get or create connection for this tenant
    const connection = await connectDB(tenantId);

    // Register Mongoose models on this connection (idempotent)
    registerAllModels(connection);

    // Attach connection to request for use in route handlers
    req.db = connection;
    req.tenantId = tenantId;

    next();
  } catch (err) {
    if (err.name === 'MongoServerSelectionError') {
      return res.status(503).json({
        success: false,
        error: 'Database temporarily unavailable',
        code: 'DB_UNAVAILABLE',
      });
    }
    res.status(500).json({
      success: false,
      error: 'Failed to connect to tenant database',
    });
  }
};

Model Registration Strategy

With dynamic connections, Mongoose models must be registered on each tenant's connection. I use a WeakSet to ensure models are registered only once per connection:

utils/loadModels.js
// WeakSet allows garbage collection of closed connections
const modelRegistry = new WeakSet();

export const registerAllModels = (conn) => {
  if (modelRegistry.has(conn)) return;  // Already registered

  // Register all models
  getProductModel(conn);
  getOrderModel(conn);
  getCustomerModel(conn);
  // ... other models
  
  modelRegistry.add(conn);
};

// Each model is a factory function
export default (conn) => {
  return conn.models.Product || conn.model('Product', productSchema);
};

Why WeakSet?

  • Allows garbage collection when connections are evicted
  • Prevents memory leaks from holding stale references
  • O(1) lookup for "has this connection been initialized?"

Monitoring & Observability

Without visibility into connection state, you're flying blind. We expose metrics via a dedicated endpoint:

/monitoring/connections
// Sample response from /monitoring/connections
{
  "activeConnections": 47,
  "pendingConnections": 0,
  "trackedTenants": 47,
  "config": {
    "poolSize": 10,
    "ttlMinutes": 30
  },
  "metrics": {
    "totalConnectionsCreated": 156,
    "totalConnectionsClosed": 109,
    "totalEvictions": 89,
    "connectionErrors": 2
  }
}

These metrics feed into Prometheus/Grafana dashboards and PagerDuty alerts for anomaly detection.

Memory Footprint Comparison

Traditional (Eager)~1.8 GB
Node
Code
300 Connections
Dynamic (Lazy)~400 MB
Node.js
Code
~50 Active
Misc

78% memory reduction by connecting only to active tenants

Connection Count Over 24 Hours

3001500
TraditionalDynamic
12AM6AM12PM6PM12AM

Dynamic connections follow actual traffic patterns, not theoretical capacity

Results & Benchmarks

After deploying this architecture across our SaaS ecosystem:

MetricBeforeAfterChange
Connections at boot3,000+0∞ better
Peak concurrent connections3,200~180-94%
Memory usage (per service)1.8 GB400 MB-78%
Cold start time45 sec3 sec-93%
EMFILE errors/week12-150-100%
MongoDB Atlas cost$580/mo$180/mo-69%
Max tenants supported~40010,000++2400%

Lessons Learned

1. Lazy Loading is Non-Negotiable at Scale

Pre-allocating resources for "potential" users is a pattern from the monolithic era. In multi-tenant SaaS, you pay for what you provision, not what you use, unless you architect around it.

Rule of thumb: If resource creation can be deferred to first access, defer it.

2. Promise Caching Prevents Catastrophe

The thundering herd problem isn't theoretical. It will crash production during traffic spikes. Cache the promise before the await.

// This pattern applies everywhere: // API rate limiting, service clients, cache warmup if (!this.pendingPromises.has(key)) { this.pendingPromises.set(key, expensiveOperation(key)); } return await this.pendingPromises.get(key);

3. TTL Must Match Access Patterns

Too short: frequent reconnection overhead during business hours. Too long: memory waste during quiet periods. We settled on 30 minutes for production, 10 minutes for staging. Make TTL configurable via environment variable.

4. Graceful Shutdown is Critical

Without proper cleanup on SIGTERM, Kubernetes restarts create "ghost" connections that exhaust MongoDB server resources.

process.on('SIGTERM', async () => { await TenantConnectionManager.closeAll(); process.exit(0); });

5. Monitoring Saves You at 3 AM

Without visibility into active connections, eviction rates, and error counts, you're flying blind. The getStats() method feeds into Prometheus dashboards, PagerDuty alerts, and capacity planning.

6. WeakSet for Connection Tracking

Using WeakSet for the model registry was a subtle but important choice. It allows garbage collection of evicted connections without manual registry cleanup, preventing memory leaks over days of uptime.

Appendix A: Compact Implementation

If you want the core pattern without all the bells and whistles, here's a minimal but production-ready version:

connectionManager.js
// connectionManager.js - Compact Version
const mongoose = require('mongoose');

class ConnectionManager {
  constructor() {
    this.connections = new Map(); // tenantId -> { conn, promise, lastUsedAt }
    this.IDLE_TIMEOUT = 10 * 60 * 1000; // 10 minutes
    this.startEvictionLoop();
  }

  async getConnection(tenantId, uri) {
    const entry = this.connections.get(tenantId);

    // If active connection exists
    if (entry && entry.conn && entry.conn.readyState === 1) {
      entry.lastUsedAt = Date.now();
      return entry.conn;
    }

    // If a connection promise already exists (Thundering Herd protection)
    if (entry && entry.promise) {
      return entry.promise;
    }

    // Create + cache the connection promise
    const connectionPromise = mongoose
      .createConnection(uri, {
        maxPoolSize: 10,
        minPoolSize: 0,
        serverSelectionTimeoutMS: 5000,
      })
      .asPromise()
      .then((conn) => {
        this.connections.set(tenantId, {
          conn,
          promise: null,
          lastUsedAt: Date.now(),
        });
        return conn;
      })
      .catch((err) => {
        this.connections.delete(tenantId);
        throw err;
      });

    // Store the promise immediately
    this.connections.set(tenantId, {
      conn: null,
      promise: connectionPromise,
      lastUsedAt: Date.now(),
    });

    return connectionPromise;
  }

  startEvictionLoop() {
    setInterval(() => {
      const now = Date.now();
      for (const [tenantId, entry] of this.connections.entries()) {
        if (entry.conn && now - entry.lastUsedAt > this.IDLE_TIMEOUT) {
          entry.conn.close();
          this.connections.delete(tenantId);
          console.log(`[DB] Closed idle connection: ${tenantId}`);
        }
      }
    }, 60 * 1000); // Check every minute
  }
}

module.exports = new ConnectionManager();

Compact Middleware Usage

middleware/tenantDb.js
// middleware/tenantDb.js
const connectionManager = require('../connectionManager');

module.exports = async (req, res, next) => {
  const tenantId = req.headers['x-tenant-id'];
  if (!tenantId) {
    return res.status(400).json({ error: 'Tenant ID required' });
  }

  const dbUri = `${process.env.MONGO_URI}-${tenantId}`;
  req.db = await connectionManager.getConnection(tenantId, dbUri);
  next();
};

That's it. No pool explosion. No EMFILE errors. No idle cost for quiet tenants.

Appendix B: Quick Reference

File Structure

text
store-product/
└── server/
    ├── app.js                          # Express app setup
    ├── config/
       ├── TenantConnectionManager.js  # The core connection manager
       ├── db.js                       # Thin wrapper for imports
       └── redis.js                    # Redis client (separate concern)
    ├── middleware/
       ├── tenantMiddleware.js         # Extracts tenant, gets connection
       └── errorMiddleware.js          # Error handling
    ├── utils/
       └── loadModels.js               # Model registration
    ├── models/
       └── product/
           ├── product.model.js        # Factory pattern models
           └── ...
    ├── controller/
       └── product.controller.js       # Uses req.db.models.Product
    └── routes/
        ├── product.route.js
        └── monitoringRoutes.js         # Exposes /monitoring/connections

Environment Variables

VariableDefaultDescription
MONGO_URI-Base MongoDB URI (tenant ID appended)
DB_POOL_SIZE10Max connections per tenant pool
DB_MIN_POOL_SIZE2Min connections to keep warm
DB_CONN_TTL_MS1800000Idle timeout (30 min)
DB_CLEANUP_INTERVAL_MS60000Cleanup loop interval (1 min)

Connection States (Mongoose)

readyStateMeaning
0Disconnected
1Connected
2Connecting
3Disconnecting

Closing Thoughts

By adopting this dynamic connection architecture, our infrastructure costs are now effectively flat. Whether we have 300 or 3,000 tenants, we only pay for active usage.

The database-per-tenant model remains the right choice for our compliance and isolation requirements. We just needed to be smarter about whenthose connections exist.

Key Takeaways

  1. Server load should scale with active tenants, not total tenants
  2. Lazy-load everything that can be deferred
  3. Cache Promises to prevent thundering herd
  4. Implement TTL-based eviction for idle resources
  5. Monitor everything, alert on anomalies

Architecting multi-tenant systems isn't about handling the tenants you have. It's about preparing for the tenants you don't have yet.

Scalability isn't about throwing more hardware at the problem. It's about ensuring resource consumption scales with actual demand, not theoretical capacity.

Build for N. Sleep well at night.