Designing a Multi-Tenant SaaS Backend: Isolation Strategies, Schema Design, and Scaling Patterns
A practical guide to the three multi-tenancy isolation models: silo, bridge, and pool. Covers schema design, tenant context propagation, noisy neighbor prevention, connection pooling, and a decision framework for SaaS teams choosing between them.
Every SaaS backend eventually confronts the same question: how do you serve multiple customers from a single codebase without letting one customer’s data leak into another’s, and without letting one customer’s traffic kill everyone else’s performance?
The answer is tenancy isolation, and the model you pick at the start shapes your schema, your deployment topology, your compliance posture, and your operational complexity for years. Most teams pick one by gut feel or by copying what they read in a blog post, then spend the next 18 months fighting the consequences.
This article lays out all three models, the schema patterns behind each, how to propagate tenant context safely through a Node.js/TypeScript stack, and a concrete framework for making the decision based on your actual constraints.
The Three Isolation Models
The industry has converged on three canonical approaches. Every multi-tenant SaaS you have ever used sits somewhere on this spectrum.
Silo: Database Per Tenant
Each tenant gets a dedicated database instance. Complete data isolation at the infrastructure layer.
When you see it: Regulated industries (healthcare, finance, government), enterprise contracts that require data residency, customers willing to pay a premium for dedicated infrastructure.
Schema pattern: No tenant columns anywhere. Tables are identical across databases. A routing layer maps tenant_id to a connection string.
Bridge: Schema Per Tenant
One database cluster, but each tenant owns a separate schema (Postgres) or collection prefix (MongoDB). Shared infrastructure, logical isolation.
When you see it: Mid-market SaaS with moderate compliance requirements, teams that want infrastructure simplicity but cannot share tables.
Schema pattern: Schema name encodes the tenant. Tables are identical across schemas. A search path (Postgres) or prefix (Mongo) routes queries.
Pool: Shared Tables
One database, one schema, one set of tables. Every row carries a tenant_id column. Isolation is enforced at the application and database policy layer.
When you see it: High-volume SaaS with thousands of small tenants, startups optimizing for infrastructure cost, teams with strong application-layer discipline.
Schema pattern: Every table has tenant_id as part of its primary key and every query includes it as a filter. Row-level security (RLS) in Postgres enforces this at the database layer.
Schema Design Per Model
Silo Schema
The connection router is the critical piece. Tenant identity must be resolved before any database call.
// tenant-router.ts
interface TenantConfig {
tenantId: string;
databaseUrl: string;
region: string;
}
class TenantRouter {
private configs: Map<string, TenantConfig> = new Map();
async resolve(tenantId: string): Promise<TenantConfig> {
if (!this.configs.has(tenantId)) {
const config = await this.loadFromControlPlane(tenantId);
this.configs.set(tenantId, config);
}
return this.configs.get(tenantId)!;
}
private async loadFromControlPlane(tenantId: string): Promise<TenantConfig> {
// Control plane DB holds tenant metadata, not tenant data
const row = await controlPlaneDb.query(
'SELECT database_url, region FROM tenants WHERE tenant_id = $1',
[tenantId]
);
if (!row) throw new Error(`Unknown tenant: ${tenantId}`);
return { tenantId, databaseUrl: row.database_url, region: row.region };
}
}
Table definitions carry no tenant context at all:
-- Applied identically to every tenant database
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
total_cents INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Bridge Schema
Schema-per-tenant in Postgres uses search_path to route queries transparently.
// schema-connection.ts
import { Pool, PoolClient } from 'pg';
const sharedPool = new Pool({ connectionString: process.env.DATABASE_URL });
async function withTenantSchema<T>(
tenantId: string,
fn: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await sharedPool.connect();
try {
// Sanitize tenantId: only allow alphanumeric and underscores
if (!/^[a-z0-9_]+$/.test(tenantId)) {
throw new Error(`Invalid tenant ID format: ${tenantId}`);
}
await client.query(`SET search_path TO tenant_${tenantId}, public`);
return await fn(client);
} finally {
// Reset search path before returning to pool
await client.query(`SET search_path TO public`);
client.release();
}
}
// Usage
const orders = await withTenantSchema('acme_corp', async (client) => {
const result = await client.query('SELECT * FROM orders WHERE user_id = $1', [userId]);
return result.rows;
});
Migration tooling needs to loop across all tenant schemas:
// migrate-all-tenants.ts
async function runMigrationsForAllTenants(migrationSql: string): Promise<void> {
const tenants = await controlPlaneDb.query('SELECT tenant_id FROM tenants WHERE status = $1', ['active']);
for (const tenant of tenants.rows) {
await withTenantSchema(tenant.tenant_id, async (client) => {
await client.query(migrationSql);
});
console.log(`Migrated tenant: ${tenant.tenant_id}`);
}
}
Pool Schema
Every table carries tenant_id. Combined with a composite primary key, this also makes partition pruning efficient in Postgres.
-- Shared table with tenant isolation
CREATE TABLE orders (
tenant_id UUID NOT NULL,
id UUID NOT NULL DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
total_cents INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, id)
);
-- Index for per-tenant queries
CREATE INDEX orders_tenant_created ON orders (tenant_id, created_at DESC);
-- Row-level security
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::UUID);
The application sets the session variable before any query:
// pool-tenant-context.ts
async function withTenant<T>(
pool: Pool,
tenantId: string,
fn: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
await client.query(
`SELECT set_config('app.tenant_id', $1, true)`,
[tenantId]
);
return await fn(client);
} finally {
client.release();
}
}
Tenant Context Propagation
Regardless of isolation model, tenant identity must flow from the HTTP request through every layer of the stack without manual threading. AsyncLocalStorage is the right tool in Node.js.
// tenant-context.ts
import { AsyncLocalStorage } from 'async_hooks';
interface TenantContext {
tenantId: string;
tenantPlan: 'starter' | 'growth' | 'enterprise';
}
const tenantStorage = new AsyncLocalStorage<TenantContext>();
export function getTenantContext(): TenantContext {
const ctx = tenantStorage.getStore();
if (!ctx) throw new Error('No tenant context. Is the middleware installed?');
return ctx;
}
export function runWithTenantContext<T>(
context: TenantContext,
fn: () => Promise<T>
): Promise<T> {
return tenantStorage.run(context, fn);
}
// tenant-middleware.ts (Express)
import { Request, Response, NextFunction } from 'express';
import { runWithTenantContext } from './tenant-context';
import { verifyJwt } from './auth';
export async function tenantMiddleware(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
res.status(401).json({ error: 'Missing token' });
return;
}
const claims = verifyJwt(token);
const tenantId = claims.tenant_id;
const tenantPlan = claims.plan;
if (!tenantId) {
res.status(401).json({ error: 'Token missing tenant_id' });
return;
}
await runWithTenantContext({ tenantId, tenantPlan }, async () => {
next();
// Wait for the response to finish so the context lives through async handlers
await new Promise<void>((resolve) => res.on('finish', resolve));
});
} catch (err) {
res.status(401).json({ error: 'Invalid token' });
}
}
Every service and repository layer calls getTenantContext() rather than accepting tenantId as a parameter. This eliminates the entire class of bugs where tenantId is accidentally dropped or defaulted.
Noisy Neighbor Prevention
The pool model is the most vulnerable to noisy neighbor problems. One tenant running a bulk export at 3 AM can saturate the connection pool or max out I/O for everyone else.
Rate Limiting Per Tenant
// tenant-rate-limiter.ts
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
interface RateLimitConfig {
windowSeconds: number;
maxRequests: number;
}
const planLimits: Record<string, RateLimitConfig> = {
starter: { windowSeconds: 60, maxRequests: 60 },
growth: { windowSeconds: 60, maxRequests: 300 },
enterprise: { windowSeconds: 60, maxRequests: 1500 },
};
export async function checkTenantRateLimit(
tenantId: string,
plan: string
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
const config = planLimits[plan] ?? planLimits.starter;
const key = `rl:${tenantId}`;
const now = Math.floor(Date.now() / 1000);
const windowStart = now - config.windowSeconds;
const pipeline = redis.multi();
pipeline.zRemRangeByScore(key, 0, windowStart);
pipeline.zCard(key);
pipeline.zAdd(key, { score: now, value: `${now}-${Math.random()}` });
pipeline.expire(key, config.windowSeconds * 2);
const results = await pipeline.exec();
const count = (results[1] as number) ?? 0;
const allowed = count < config.maxRequests;
const resetAt = now + config.windowSeconds;
return {
allowed,
remaining: Math.max(0, config.maxRequests - count - 1),
resetAt,
};
}
Query Timeouts Per Tenant
Heavy queries from one tenant should not hold locks or connections for extended periods.
// tenant-query-runner.ts
const planTimeouts: Record<string, number> = {
starter: 5_000, // 5 seconds
growth: 30_000, // 30 seconds
enterprise: 120_000, // 2 minutes
};
async function runTenantQuery<T>(
client: PoolClient,
sql: string,
params: unknown[]
): Promise<T> {
const { tenantPlan } = getTenantContext();
const timeoutMs = planTimeouts[tenantPlan] ?? planTimeouts.starter;
await client.query(`SET statement_timeout = ${timeoutMs}`);
const result = await client.query(sql, params);
return result.rows as T;
}
Tenant-Aware Connection Pooling
For silo and bridge models, you cannot use a single global pool. You need per-tenant pools with lifecycle management to avoid holding idle connections for thousands of tenants.
// tenant-pool-manager.ts
import { Pool } from 'pg';
interface PoolEntry {
pool: Pool;
lastUsed: number;
}
class TenantPoolManager {
private pools: Map<string, PoolEntry> = new Map();
private readonly maxIdleMs = 5 * 60 * 1000; // 5 minutes
private readonly maxPoolSize = 5;
getPool(tenantId: string, connectionString: string): Pool {
const existing = this.pools.get(tenantId);
if (existing) {
existing.lastUsed = Date.now();
return existing.pool;
}
const pool = new Pool({
connectionString,
max: this.maxPoolSize,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
this.pools.set(tenantId, { pool, lastUsed: Date.now() });
return pool;
}
async evictIdle(): Promise<void> {
const now = Date.now();
for (const [tenantId, entry] of this.pools) {
if (now - entry.lastUsed > this.maxIdleMs) {
await entry.pool.end();
this.pools.delete(tenantId);
}
}
}
}
const poolManager = new TenantPoolManager();
// Evict idle pools every minute
setInterval(() => poolManager.evictIdle(), 60_000);
This matters at scale. 5,000 tenants at 5 connections each is 25,000 connections if you are not careful.
Migrating Between Isolation Models
Teams that start with the pool model often need to move to bridge or silo as a specific customer signs an enterprise contract requiring dedicated infrastructure.
The migration pattern for pool-to-silo for a single tenant:
// migrate-tenant-to-silo.ts
async function migrateTenantToSilo(
tenantId: string,
targetConnectionString: string
): Promise<void> {
const targetPool = new Pool({ connectionString: targetConnectionString });
// 1. Create schema in target database
await targetPool.query(`
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
total_cents INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
// 2. Copy data in batches (never bulk-copy without batching)
let lastId: string | null = null;
const batchSize = 1000;
while (true) {
const rows = await sourcePool.query(
`SELECT id, user_id, total_cents, created_at
FROM orders
WHERE tenant_id = $1
AND ($2::UUID IS NULL OR id > $2)
ORDER BY id
LIMIT $3`,
[tenantId, lastId, batchSize]
);
if (rows.rows.length === 0) break;
// Insert batch into target
for (const row of rows.rows) {
await targetPool.query(
`INSERT INTO orders (id, user_id, total_cents, created_at) VALUES ($1, $2, $3, $4)`,
[row.id, row.user_id, row.total_cents, row.created_at]
);
}
lastId = rows.rows[rows.rows.length - 1].id;
console.log(`Migrated ${rows.rows.length} orders, last id: ${lastId}`);
}
// 3. Verify row counts match before cutover
const sourceCount = await sourcePool.query(
'SELECT count(*) FROM orders WHERE tenant_id = $1',
[tenantId]
);
const targetCount = await targetPool.query('SELECT count(*) FROM orders');
if (sourceCount.rows[0].count !== targetCount.rows[0].count) {
throw new Error('Row count mismatch. Aborting cutover.');
}
// 4. Update control plane to route tenant to new connection string
await controlPlaneDb.query(
'UPDATE tenants SET database_url = $1, isolation_model = $2 WHERE tenant_id = $3',
[targetConnectionString, 'silo', tenantId]
);
console.log(`Tenant ${tenantId} migrated to silo successfully`);
}
Always run migrations with a cutover window where writes to the source are paused or replicated. For a production migration, use logical replication or a CDC tool rather than a batch copy so you can keep the target in sync until the cutover moment.
Tradeoffs Comparison
| Dimension | Silo (DB per tenant) | Bridge (Schema per tenant) | Pool (Shared tables) |
|---|---|---|---|
| Data isolation | Strongest: OS-level | Strong: schema boundary | Weakest: app enforced |
| Cost at scale | High: N databases | Medium: shared cluster | Low: one cluster |
| Noisy neighbor risk | None | Low | High without quotas |
| Migration complexity | Low per tenant | Medium | High (all rows carry tenant_id) |
| Compliance (SOC 2, HIPAA) | Easiest | Moderate | Requires strong RLS evidence |
| Schema migrations | Per tenant, parallelizable | Loop all schemas | Single migration |
| Onboarding new tenant | Provision database (minutes) | Create schema (milliseconds) | No-op |
| Max tenant count | Hundreds (DB limits) | Thousands (schema limits) | Millions |
| Cross-tenant analytics | Complex (federation) | Medium (FDW) | Simple |
| Operational complexity | High | Medium | Low |
Production Considerations
RLS is not enough on its own. Row-level security in Postgres is a last line of defense, not the primary control. It fails silently if you use a superuser role for application queries (which many ORM connection strings do by default). Use a dedicated application role with RLS enabled.
Control plane separation. Always run a separate database for tenant metadata (connection strings, plan info, feature flags). Mixing control plane data into a tenant database creates a chicken-and-egg problem during incident response.
Tenant ID in logs. Every log line that touches tenant data should include tenant_id. Structured logging with a tenant context makes incident response and billing audits tractable.
Test isolation guarantees explicitly. Write tests that deliberately attempt cross-tenant data access and assert that they fail or return empty results. These tests are cheap to write and catch regressions before they become incidents.
Connection pool sizing math. With the pool model, total connections = (max pool size) x (number of app instances). With the silo model, add the per-tenant pool max across active tenants. Postgres has a hard connection limit (default 100). PgBouncer or a similar proxy is nearly always necessary in production.
Soft deletes complicate RLS. If you use deleted_at IS NULL filters throughout your queries, make sure your RLS policies account for them. A soft-deleted row is still a data isolation concern.
Decision Framework
Start here and work through the questions in order:
1. Do you have contracts requiring dedicated infrastructure? If yes, start with silo or plan to support it. You cannot retrofit silo later without a significant migration project.
2. How many tenants do you expect in year one? Under 100: silo is operationally manageable. 100 to 10,000: bridge is the practical middle ground. Over 10,000: pool is the only cost-viable option.
3. What is your team size? A team under 5 engineers should not operate N databases. The operational burden of patching, backing up, and monitoring silo databases is real. Bridge or pool with strong RLS is more appropriate.
4. What is your compliance exposure? If you are targeting healthcare, finance, or government, the compliance audit cost for a pool model is higher because you must demonstrate RLS correctness to auditors. Silo or bridge simplifies the conversation.
5. Do you need cross-tenant analytics today? If your product or business requires querying across all tenants (usage aggregation, cohort analysis, anomaly detection), the pool model is far simpler. With silo, you need a federation layer or a separate analytics database fed by ETL.
Most early-stage SaaS products are better served starting with the pool model (lower cost, simpler operations, instant tenant onboarding) and building the silo path only when an enterprise contract demands it. Build the abstraction layer from day one so the migration is a configuration change, not a rewrite.
The isolation model you pick is not permanent, but migrating between them is expensive enough that the decision deserves deliberate thought. Match the model to your actual compliance requirements, team capacity, and tenant profile rather than to what sounds most sophisticated. The best architecture is the one your team can operate at 2 AM without reading documentation.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.