Every SaaS product eventually answers the same question: how do we keep tenants' data apart, their workloads from stepping on each other, and our infrastructure cheap enough to run? The answer is never one model. Most mature SaaS products end up with a tiered architecture — a shared pool for free and starter customers, a heavier isolation tier for enterprise — because the economics and the compliance requirements pull in opposite directions. This post walks through the three canonical isolation models, when to pick which, how to enforce tenant boundaries with Postgres row-level security, where the noisy-neighbor and tenancy-bug traps are, and the middleware patterns that make tenant context easy to get right and hard to get wrong.
The three isolation models
Every multi-tenant design is some combination of three patterns. The names vary across AWS whitepapers and blog posts, but the mechanics are fixed.
| Model | Data layout | Isolation strength | Cost per tenant | Best for |
|---|---|---|---|---|
| Shared DB + shared schema | One database, one set of tables, tenant_id column on every row | Application or RLS enforced | Lowest | Free tier, starter, product-led growth |
| Shared DB + separate schema | One database, per-tenant schema with identical tables | Schema-level; strong but bounded by DB limits | Low | Mid-market; mild compliance needs |
| Database per tenant | Dedicated database (or cluster) per tenant | Full — physical separation | Highest | Enterprise, regulated data, residency |
The trap is assuming you have to pick one. A well-structured SaaS product in 2026 usually tiers: a shared pool for everyone up to mid-market, a separate schema or dedicated DB sold as an enterprise upsell, and residency-specific clusters for regulated regions. The tiered model turns isolation into a pricing lever rather than an engineering constraint.
Shared DB, shared schema — the default you should start with
Every tenant-scoped table gets a tenant_id column, every index leads with tenant_id, and every query includes a tenant predicate. At the cost of one extra column and a bit of query discipline, you get the cheapest and most operationally simple model. The price is that any bug that omits the tenant filter leaks data across tenants. This is the single most dangerous class of bug in multi-tenant SaaS, and the reason row-level security exists.
Postgres row-level security, the belt for your suspenders
RLS lets Postgres enforce tenant isolation at the database layer — every SELECT, UPDATE, DELETE is automatically filtered by a policy, whether or not the application remembered to add a WHERE clause. The pattern:
-- Every tenant-scoped table gets a tenant_id column
ALTER TABLE invoices ADD COLUMN tenant_id uuid NOT NULL;
CREATE INDEX idx_invoices_tenant ON invoices (tenant_id, created_at DESC);
-- Turn on RLS
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY; -- applies to table owner too
-- Policy: only rows matching the current session's tenant are visible
CREATE POLICY tenant_isolation ON invoices
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
-- Application sets the tenant per request, inside a transaction
-- This is critical — SET LOCAL only lasts for the transaction
BEGIN;
SELECT set_config('app.tenant_id', $1, true); -- true = local to tx
SELECT * FROM invoices WHERE status = 'open';
COMMIT;CVE-2024-10976 disclosed that RLS policies applied below subqueries could disregard mid-session user-ID changes, which in a pooled-connection setup can leak one tenant's rows into another tenant's query. Patch your Postgres, use set_config with local=true inside a transaction, and reset the setting at the end of every request. Never rely on the application-layer filter alone.
The N+1 tenancy bug, and how to avoid it
The N+1 tenancy bug is the subtle cousin of the classic N+1 query. A list endpoint fetches 50 rows scoped to tenant A, then a post-processing step enriches each row with a secondary lookup — and the secondary lookup forgets the tenant filter. If the join key happens to overlap across tenants, tenant A sees tenant B's data. No error. No alarm. The bug only surfaces when a customer reads their own records and spots something that isn't theirs.
Any helper that takes an id and returns an object must require a tenant. Make it a function signature thing, not a discipline thing. getInvoice(invoiceId, tenantId) instead of getInvoice(invoiceId). If your ORM silently executes 'SELECT * FROM invoices WHERE id = ?', wrap it.
RLS plus a tenant-in-signature convention is a belt-and-suspenders defense. RLS catches the bug at runtime; the function signature catches it at review time. Run both.
Tenant context middleware — where tenant_id comes from
The tenant ID lives in exactly one place on any given request: a middleware that extracts it from the authenticated identity, validates the caller belongs to it, and stashes it somewhere downstream code will find. In Node services, that 'somewhere' is usually AsyncLocalStorage — every log line, every DB call, every queue job picks it up automatically.
import { AsyncLocalStorage } from "node:async_hooks";
export const tenantContext = new AsyncLocalStorage<{ tenantId: string }>();
// Extract tenant from the authenticated session, validate, and run
// the rest of the request inside the async local store
export async function tenantMiddleware(req, res, next) {
const userId = req.session?.userId;
if (!userId) return res.status(401).end();
const tenantId = req.headers["x-tenant-id"] ?? req.session.activeTenantId;
if (!tenantId) return res.status(400).send("missing tenant");
// Critical: never trust the header alone — verify membership
const ok = await db.membership.exists({ userId, tenantId });
if (!ok) return res.status(403).end();
tenantContext.run({ tenantId }, () => next());
}
// Every DB call pulls the tenant from the store and sets it on the session
export async function query<T>(sql: string, params: unknown[]): Promise<T[]> {
const ctx = tenantContext.getStore();
if (!ctx) throw new Error("no tenant context");
return db.transaction(async (tx) => {
await tx.execute("SELECT set_config('app.tenant_id', $1, true)", [ctx.tenantId]);
return tx.query<T>(sql, params);
});
}Two non-obvious pieces in this pattern. First, the membership check — any caller can send an X-Tenant-ID header, so the middleware has to confirm the authenticated user actually belongs to the claimed tenant on every request. Second, the AsyncLocalStorage wrap — that's what makes the tenant available deep in a call stack without threading it through every function argument. Logs, background jobs kicked off during the request, and the database layer all read from the same store.
Noisy neighbors and per-tenant throttling
In a shared-pool architecture, one tenant can starve every other tenant of CPU, database connections, or rate-limited third-party APIs. The fix is a mix of containment at the edge (per-tenant rate limits) and containment at the database (statement timeouts, connection quotas, query cost ceilings).
- Per-tenant rate limits at the API gateway — a token bucket keyed on tenant_id, not IP. Free tier gets a low ceiling; paid tiers get higher limits. Bursty tenants can't drown the pool.
- Statement timeouts in Postgres — SET statement_timeout = '10s' at the session level stops runaway queries from pegging the CPU. Combine with a query-level timeout for OLAP-style endpoints.
- Connection quotas per tenant — pgBouncer plus per-tenant pool limits, or a tenant-aware connection checkout. Without a cap, a single tenant with a leaked connection can exhaust the pool.
- Queue priority lanes — a separate queue or a priority key per tier. Enterprise jobs never wait behind a backfill that a free-tier tenant kicked off.
- Per-tenant observability — track request rate, latency, and DB CPU by tenant. When something breaks, you want to know whether it's one customer or the whole base before you wake up an engineer.
When to break the shared pool
Four signals justify moving a tenant off the shared pool and onto a separate schema or a dedicated database. First, compliance — HIPAA, GDPR, regional data residency, a customer contract that requires separate storage. Second, workload shape — a customer with 100× the row count or query volume of a normal tenant will blow the shared query plans. Third, commercial — enterprise customers will pay a premium for dedicated infrastructure, and offering it as a price tier turns isolation into revenue. Fourth, blast radius — a VIP customer whose outage is a business-existential event often warrants its own stack.
A common pattern: every tenant has a 'pool_id' on its record. 'pool_id = 0' means shared. Non-zero means dedicated — the connection layer reads pool_id and routes to the right cluster. Starting with this indirection from day one makes the migration from shared to dedicated a data change, not a code change.
Scaling and sharding across tenants
Past a certain scale, a single shared database can't hold every tenant. The usual move is to shard by tenant_id — every tenant lives on exactly one shard, and queries are never cross-shard. The lookup service (tenant_id → shard) is a small, heavily-cached table. Tenants stay on their shard unless ops moves them manually. This is the model that lets you scale horizontally without the complexity of distributed transactions.
The place this gets hard is reporting. Cross-tenant analytics — the admin dashboard that shows you your ARR, your top tenants by usage, your global billing run — has to fan out across shards. Most teams solve this by streaming tenant data into a warehouse via CDC and running analytics there, not against the production shards. Admin queries and operational queries belong on different systems.
Key takeaways
- Start with shared DB + shared schema and a tenant_id on every row. Add RLS as defense-in-depth from day one.
- Tier your isolation. Enterprise customers pay for a dedicated schema or database; free and mid-market live on the shared pool.
- Put tenant context in one middleware and one AsyncLocalStorage. Never thread tenant_id through function arguments.
- Make every helper require a tenant in its signature. It's the cheapest N+1-tenancy-bug prevention available.
- Per-tenant rate limits, statement timeouts, and connection quotas — pick all three, not one. Noisy neighbors find the weakest bound.
- Design for sharding before you need it. A pool_id indirection from day one makes horizontal scale a migration, not a rewrite.