Multi-tenant SaaS architecture is the foundation of scalable software-as-a-service platforms. But many companies make costly mistakes: over-provisioning resources, choosing wrong isolation strategies, or scaling inefficiently.
This guide shows you how to build a multi-tenant SaaS architecture that scales cost-effectively. You'll learn database strategies, resource isolation patterns, and scaling techniques that keep infrastructure costs manageable while supporting growth from 100 to 100,000+ tenants.
The Cost Problem with Multi-Tenancy
Common mistakes that inflate costs:
- Separate databases per tenant (10x infrastructure costs)
- Over-provisioning for peak loads (wasted resources)
- No resource pooling (inefficient utilization)
- Poor query optimization (expensive database operations)
The goal: Support thousands of tenants on infrastructure that costs 80% less than naive approaches.
Multi-Tenant Architecture Patterns
Pattern 1: Shared Database, Shared Schema (Recommended for Most)
How it works: All tenants share the same database and tables, with a tenant_id column for isolation.
Cost: Lowest infrastructure cost Complexity: Low Best for: 90% of SaaS applications
-- Single database, tenant_id in every table
CREATE TABLE users (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
email VARCHAR(255) NOT NULL,
name VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
);
CREATE TABLE documents (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
title VARCHAR(255),
content TEXT,
created_at TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (tenant_id) REFERENCES tenants(id),
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- Index for tenant isolation
CREATE INDEX idx_users_tenant ON users(tenant_id);
CREATE INDEX idx_documents_tenant ON documents(tenant_id);
Application-level isolation:
// middleware/tenant-context.js
const { Pool } = require('pg');
class TenantContext {
constructor() {
this.pool = new Pool({
host: process.env.DB_HOST,
database: 'saas_db',
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
max: 20, // Connection pool shared across tenants
idleTimeoutMillis: 30000
});
}
// Always filter by tenant_id
async query(sql, params, tenantId) {
// Automatically append tenant_id to WHERE clause
const tenantFilter = `AND tenant_id = $${params.length + 1}`;
const modifiedSql = sql.includes('WHERE')
? sql.replace('WHERE', `WHERE tenant_id = $${params.length + 1} AND`)
: sql + ` WHERE tenant_id = $${params.length + 1}`;
return this.pool.query(modifiedSql, [...params, tenantId]);
}
}
module.exports = new TenantContext();
Usage:
// routes/documents.js
const tenantContext = require('../middleware/tenant-context');
router.get('/api/documents', authenticateTenant, async (req, res) => {
const tenantId = req.tenant.id;
// Automatically scoped to tenant
const documents = await tenantContext.query(
'SELECT * FROM documents ORDER BY created_at DESC',
[],
tenantId
);
res.json(documents.rows);
});
Cost analysis:
- Infrastructure: 1 database instance
- Storage: Shared across all tenants
- Connections: Pooled (20 connections serve 1000+ tenants)
- Monthly cost: ~$200-500 (vs $20,000+ for separate databases)
Pattern 2: Shared Database, Separate Schemas
How it works: One database, separate schema per tenant.
Cost: Low-medium Complexity: Medium Best for: Regulatory requirements (data isolation needed)
-- Create schema per tenant
CREATE SCHEMA tenant_acme_corp;
CREATE SCHEMA tenant_globex_inc;
-- Tables in each schema
CREATE TABLE tenant_acme_corp.users (...);
CREATE TABLE tenant_globex_inc.users (...);
Application routing:
class SchemaBasedTenantContext {
async getTenantSchema(tenantId) {
const tenant = await this.pool.query(
'SELECT schema_name FROM tenants WHERE id = $1',
[tenantId]
);
return tenant.rows[0].schema_name; // e.g., 'tenant_acme_corp'
}
async query(sql, params, tenantId) {
const schema = await this.getTenantSchema(tenantId);
const schemaPrefixedSql = sql.replace(
/FROM\s+(\w+)/g,
`FROM ${schema}.$1`
);
return this.pool.query(schemaPrefixedSql, params);
}
}
Cost analysis:
- Infrastructure: 1 database instance
- Storage: Shared
- Connections: Pooled
- Monthly cost: ~$300-700 (slightly higher due to schema overhead)
Pattern 3: Separate Databases (Use Sparingly)
When to use: Only for enterprise tenants with specific requirements.
Cost: High Complexity: High Best for: < 5% of tenants (enterprise tier)
// Dynamic database routing
class DatabaseRouter {
constructor() {
this.pools = new Map();
}
async getPool(tenantId) {
const tenant = await this.getTenantConfig(tenantId);
if (tenant.hasDedicatedDb) {
// Enterprise tenant with dedicated database
if (!this.pools.has(tenantId)) {
this.pools.set(tenantId, new Pool({
database: `tenant_${tenantId}`,
// ... config
}));
}
return this.pools.get(tenantId);
} else {
// Shared database pool
return this.sharedPool;
}
}
}
Cost Optimization Strategies
1. Connection Pooling
Problem: Creating a connection per tenant = expensive.
Solution: Shared connection pool:
// Single pool for all tenants
const pool = new Pool({
max: 20, // 20 connections serve 1000+ tenants
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000
});
// Connection per request, not per tenant
async function handleRequest(tenantId) {
const client = await pool.connect();
try {
// Use connection
await client.query('SELECT * FROM users WHERE tenant_id = $1', [tenantId]);
} finally {
client.release(); // Return to pool
}
}
Savings: 95% reduction in connection overhead.
2. Query Optimization
Problem: Queries without proper indexes scan entire tables.
Solution: Composite indexes on tenant_id + common filters:
-- Efficient: Index on tenant_id + created_at
CREATE INDEX idx_documents_tenant_created
ON documents(tenant_id, created_at DESC);
-- Query uses index
SELECT * FROM documents
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT 20;
-- Uses index: O(log n) instead of O(n)
Query performance:
- Without index: 500ms for 1M rows
- With index: 5ms for 1M rows
- 100x faster = lower database costs
3. Row-Level Security (PostgreSQL)
Automatic tenant isolation at database level:
-- Enable RLS
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- Policy: users can only see their tenant's data
CREATE POLICY tenant_isolation ON documents
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- Set tenant context per connection
SET app.current_tenant_id = 'tenant-uuid-here';
Benefits:
- Impossible to leak data between tenants (database enforces)
- No application-level bugs can bypass isolation
- Simpler application code
4. Caching Strategy
Problem: Repeated queries for same tenant data.
Solution: Multi-level caching:
const Redis = require('redis');
const client = Redis.createClient();
class TenantCache {
async getTenant(tenantId) {
// L1: Memory cache (fastest)
if (this.memoryCache.has(tenantId)) {
return this.memoryCache.get(tenantId);
}
// L2: Redis cache (fast)
const cached = await client.get(`tenant:${tenantId}`);
if (cached) {
const tenant = JSON.parse(cached);
this.memoryCache.set(tenantId, tenant);
return tenant;
}
// L3: Database (slowest)
const tenant = await db.query(
'SELECT * FROM tenants WHERE id = $1',
[tenantId]
);
// Cache for future requests
await client.setex(`tenant:${tenantId}`, 3600, JSON.stringify(tenant));
this.memoryCache.set(tenantId, tenant);
return tenant;
}
}
Savings: 90% reduction in database queries for tenant lookups.
5. Resource-Based Scaling
Problem: Scaling for peak load wastes money during low usage.
Solution: Auto-scaling based on actual usage:
// Monitor tenant resource usage
class ResourceMonitor {
async getTenantMetrics(tenantId) {
return {
requestCount: await this.getRequestCount(tenantId),
databaseQueries: await this.getQueryCount(tenantId),
storageUsed: await this.getStorageUsed(tenantId)
};
}
async shouldScaleUp(tenantId) {
const metrics = await this.getTenantMetrics(tenantId);
return metrics.requestCount > 10000; // Threshold
}
}
Cost savings: Pay only for resources you use, not peak capacity.
Real-World Cost Comparison
Scenario: 1,000 tenants, average 10,000 requests/month per tenant
Approach 1: Separate Database Per Tenant
- Infrastructure: 1,000 database instances
- Monthly cost: ~$20,000
- Complexity: Very high
Approach 2: Shared Database, Shared Schema (Recommended)
- Infrastructure: 1 database instance (scaled)
- Monthly cost: ~$500
- Complexity: Low
- Savings: 97.5%
Approach 3: Hybrid (Shared + Dedicated for Enterprise)
- Infrastructure: 1 shared DB + 5 dedicated DBs
- Monthly cost: ~$1,500
- Complexity: Medium
- Savings: 92.5%
Security Considerations
Tenant Data Isolation
Critical: Never leak data between tenants.
// ❌ WRONG: Missing tenant filter
router.get('/api/users', async (req, res) => {
const users = await db.query('SELECT * FROM users');
res.json(users); // LEAK: Returns all tenants' users!
});
// ✅ CORRECT: Always filter by tenant
router.get('/api/users', authenticateTenant, async (req, res) => {
const tenantId = req.tenant.id;
const users = await db.query(
'SELECT * FROM users WHERE tenant_id = $1',
[tenantId]
);
res.json(users);
});
Testing Tenant Isolation
// test/tenant-isolation.test.js
describe('Tenant Isolation', () => {
it('should not leak data between tenants', async () => {
const tenant1 = await createTestTenant();
const tenant2 = await createTestTenant();
await createDocument(tenant1.id, 'Secret Document');
const tenant2Docs = await getDocuments(tenant2.id);
expect(tenant2Docs).not.toContain('Secret Document');
});
});
Scaling Patterns
Horizontal Scaling
// Load balancer distributes requests
// Each app instance connects to shared database
const appInstances = [
'app-instance-1:3000',
'app-instance-2:3000',
'app-instance-3:3000'
];
// Database connection pool handles concurrent requests
const pool = new Pool({
max: 100, // Supports multiple app instances
// ...
});
Database Read Replicas
// Write to primary, read from replicas
class DatabaseCluster {
constructor() {
this.writePool = new Pool({
host: 'db-primary.example.com'
});
this.readPools = [
new Pool({ host: 'db-replica-1.example.com' }),
new Pool({ host: 'db-replica-2.example.com' })
];
}
async query(sql, params, tenantId, isWrite = false) {
const pool = isWrite
? this.writePool
: this.getReadPool(); // Round-robin
return pool.query(sql, params);
}
}
Monitoring and Optimization
Key Metrics to Track
- Database connection pool usage: Should stay < 80%
- Query performance: P95 latency < 100ms
- Tenant resource usage: Identify heavy tenants
- Cache hit rate: Should be > 80%
Cost Monitoring
// Track costs per tenant
class CostTracker {
async calculateTenantCost(tenantId, period) {
const metrics = await this.getTenantMetrics(tenantId, period);
return {
databaseQueries: metrics.queries * 0.0001, // $0.0001 per query
storage: metrics.storageGB * 0.10, // $0.10 per GB
requests: metrics.requests * 0.00001, // $0.00001 per request
total: /* sum */
};
}
}
Migration Path: From Single-Tenant to Multi-Tenant
If you're starting single-tenant and need to migrate:
- Add tenant_id column to all tables
- Backfill data with default tenant
- Update queries to include tenant_id filter
- Add tenant context middleware
- Test isolation thoroughly
- Gradually onboard new tenants
Conclusion
Building a cost-efficient multi-tenant SaaS architecture requires choosing the right pattern (shared database, shared schema for most cases), optimizing queries, pooling resources, and scaling intelligently.
The shared database approach can reduce infrastructure costs by 95%+ compared to separate databases, while maintaining security and performance.
Key takeaways:
- Use shared database, shared schema for 90% of use cases
- Always filter by tenant_id (enforce at database level with RLS)
- Pool connections and cache aggressively
- Monitor and optimize continuously
Next Steps
Planning a multi-tenant SaaS architecture? Contact OceanSoft Solutions to discuss your requirements. We specialize in building scalable, cost-efficient SaaS platforms.
Related Resources:
Have questions about multi-tenant architecture? Reach out at contact@oceansoftsol.com.