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.
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.
Table of Contents
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:
300 tenants × 10 pool connections ≈ 3,000 open socketsMost of these tenants were idle, yet the server was paying a full-time penalty for them.
Connection Utilization at 300 Tenants
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
# The errors that woke me up at 2 AM:
Error: EMFILE: too many open files
MongoServerSelectionError: connection pool exhaustedThis 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:
- Loop through all tenant configs
- Create one Mongoose connection per tenant
- Cache models per connection
- 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 guaranteedMemory 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 connectionsTCP 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.
// 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 databaseSecurity & 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.
| Tenants | Pool Size | Connections | Status |
|---|---|---|---|
| 10 | 10 | 100 | ✓ Fine |
| 100 | 10 | 1,000 | ⚠ Noticeable |
| 300 | 10 | 3,000 | ✗ Crash |
| 1,000+ | 10 | 10,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
300 connections created at startup
Dynamic: Lazy Loading
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
awaitthe same Promise - Only one connection is ever created per tenant
// ❌ 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 itPrinciple 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
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:
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
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
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:
// 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:
// 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
78% memory reduction by connecting only to active tenants
Connection Count Over 24 Hours
Dynamic connections follow actual traffic patterns, not theoretical capacity
Results & Benchmarks
After deploying this architecture across our SaaS ecosystem:
| Metric | Before | After | Change |
|---|---|---|---|
| Connections at boot | 3,000+ | 0 | ∞ better |
| Peak concurrent connections | 3,200 | ~180 | -94% |
| Memory usage (per service) | 1.8 GB | 400 MB | -78% |
| Cold start time | 45 sec | 3 sec | -93% |
| EMFILE errors/week | 12-15 | 0 | -100% |
| MongoDB Atlas cost | $580/mo | $180/mo | -69% |
| Max tenants supported | ~400 | 10,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 - 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
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
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/connectionsEnvironment Variables
| Variable | Default | Description |
|---|---|---|
| MONGO_URI | - | Base MongoDB URI (tenant ID appended) |
| DB_POOL_SIZE | 10 | Max connections per tenant pool |
| DB_MIN_POOL_SIZE | 2 | Min connections to keep warm |
| DB_CONN_TTL_MS | 1800000 | Idle timeout (30 min) |
| DB_CLEANUP_INTERVAL_MS | 60000 | Cleanup loop interval (1 min) |
Connection States (Mongoose)
| readyState | Meaning |
|---|---|
| 0 | Disconnected |
| 1 | Connected |
| 2 | Connecting |
| 3 | Disconnecting |
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
- Server load should scale with active tenants, not total tenants
- Lazy-load everything that can be deferred
- Cache Promises to prevent thundering herd
- Implement TTL-based eviction for idle resources
- 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.