System Design ·

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.

Designing a Multi-Tenant SaaS Backend: Isolation Strategies, Schema Design, and Scaling Patterns

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

DimensionSilo (DB per tenant)Bridge (Schema per tenant)Pool (Shared tables)
Data isolationStrongest: OS-levelStrong: schema boundaryWeakest: app enforced
Cost at scaleHigh: N databasesMedium: shared clusterLow: one cluster
Noisy neighbor riskNoneLowHigh without quotas
Migration complexityLow per tenantMediumHigh (all rows carry tenant_id)
Compliance (SOC 2, HIPAA)EasiestModerateRequires strong RLS evidence
Schema migrationsPer tenant, parallelizableLoop all schemasSingle migration
Onboarding new tenantProvision database (minutes)Create schema (milliseconds)No-op
Max tenant countHundreds (DB limits)Thousands (schema limits)Millions
Cross-tenant analyticsComplex (federation)Medium (FDW)Simple
Operational complexityHighMediumLow

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
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
System Design ·

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
System Design ·

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
System Design ·

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.