Designing for 10x Growth — What Changes, What Stays the Same
Advertisement
Introduction
Why This Matters
Engineers routinely over-engineer systems for scale they will never reach. The advice you read about Kafka, sharding, and microservices was written for teams operating at 100x or 1,000x your current load. Applying those patterns at 10x growth adds operational complexity without meaningful benefit — and often makes things slower, not faster.
Understanding what actually changes at 10x helps you invest engineering time where it delivers real results.
What Changes at 10x (and What Does Not)
The jump from 1,000 to 10,000 users exposes a specific set of bottlenecks. These are predictable and well-understood.
What DOES matter at 10x:
→ Missing database indexes
(5ms query at 1k rows becomes 5s at 100k rows without index)
→ N+1 query patterns
(invisible at small scale, catastrophic at 10x)
→ Connection pool sizing
(10 DB connections fine at 100 RPM; need 50+ at 1,000 RPM)
→ Caching for expensive repeated reads
(product catalog, user profile, config lookups)
→ Synchronous blocking calls in hot paths
→ Missing LIMIT clauses on unbounded queries
What does NOT matter at 10x:
→ Kafka vs RabbitMQ vs SQS (pick any)
→ Microservices vs monolith
→ Multi-region replication
→ Custom load balancers
→ Database shardingDatabase Index Patterns
Indexes are the highest-ROI optimization at 10x scale. A single missing index can turn a fast query into a full table scan.
-- Find slow queries in PostgreSQL
SELECT
query,
calls,
mean_exec_time,
total_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;
-- Check which queries do sequential scans
SELECT
schemaname,
tablename,
seq_scan,
seq_tup_read,
idx_scan,
idx_tup_fetch
FROM pg_stat_user_tables
WHERE seq_scan > idx_scan
ORDER BY seq_tup_read DESC;
-- Common index patterns that fix 10x problems
-- Composite index for filtered + sorted queries
CREATE INDEX CONCURRENTLY idx_orders_user_status_created
ON orders(user_id, status, created_at DESC);
-- Partial index for hot subset of rows
CREATE INDEX CONCURRENTLY idx_orders_pending
ON orders(created_at)
WHERE status = 'pending';
-- Index for LIKE prefix searches
CREATE INDEX CONCURRENTLY idx_users_email_prefix
ON users(email text_pattern_ops);Connection Pool Sizing
At 10x load, under-sized connection pools become a hard ceiling. Every request waits for a connection to free up.
// src/db/pool.ts
import { Pool } from 'pg';
// Wrong: default pool settings collapse at 10x
const badPool = new Pool({
connectionString: process.env.DATABASE_URL,
// Default max: 10 — fine for 100 RPM, broken at 1,000 RPM
});
// Right: size pool based on your request rate and query duration
function calculatePoolSize(): number {
// Rule of thumb: pool_size = (available_connections / num_app_instances)
// If DB allows 100 connections and you run 4 app servers:
// pool_size = 100 / 4 = 25 per instance
const dbMaxConnections = parseInt(process.env.DB_MAX_CONNECTIONS || '100');
const appInstances = parseInt(process.env.APP_INSTANCES || '4');
const overhead = 10; // reserved for migrations, admin, monitoring
return Math.floor((dbMaxConnections - overhead) / appInstances);
}
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: calculatePoolSize(), // e.g., 22
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
// Statement timeout prevents runaway queries from holding connections
statement_timeout: 10_000,
});
// Monitor pool health
setInterval(() => {
console.log({
total: pool.totalCount,
idle: pool.idleCount,
waiting: pool.waitingCount,
});
}, 30_000);Fixing N+1 Query Patterns
N+1 queries are invisible at small scale and catastrophic at 10x. One request spawning 50 DB queries is fine when you have 100 requests/minute; at 1,000 RPM, that is 50,000 queries/minute.
// src/api/orders.ts
// BROKEN: N+1 pattern
async function getOrdersWithCustomers_BAD(orderIds: string[]) {
const orders = await db.query(
'SELECT * FROM orders WHERE id = ANY($1)',
[orderIds]
);
// This fires one query PER order — N+1
for (const order of orders.rows) {
order.customer = await db.query(
'SELECT * FROM customers WHERE id = $1',
[order.customer_id]
);
}
return orders.rows;
}
// FIXED: Batch with a single JOIN
async function getOrdersWithCustomers(orderIds: string[]) {
const result = await db.query(`
SELECT
o.*,
c.name AS customer_name,
c.email AS customer_email
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.id = ANY($1)
`, [orderIds]);
return result.rows;
}
// FIXED: Batch with separate query + map (when JOIN is too costly)
async function getOrdersWithCustomers_v2(orderIds: string[]) {
const [orders, customers] = await Promise.all([
db.query('SELECT * FROM orders WHERE id = ANY($1)', [orderIds]),
db.query(`
SELECT * FROM customers
WHERE id IN (
SELECT DISTINCT customer_id FROM orders WHERE id = ANY($1)
)
`, [orderIds]),
]);
const customerMap = new Map(customers.rows.map(c => [c.id, c]));
return orders.rows.map(order => ({
...order,
customer: customerMap.get(order.customer_id),
}));
}Caching Strategy for 10x
A read cache for expensive, frequently-accessed, slowly-changing data eliminates a huge class of 10x problems without any schema changes.
// src/cache/product-cache.ts
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
const PRODUCT_TTL = 300; // 5 minutes — appropriate for catalog data
export async function getProduct(productId: string) {
const cacheKey = `product:${productId}`;
// Check cache first
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Cache miss: fetch from DB
const product = await db.query(
'SELECT * FROM products WHERE id = $1',
[productId]
);
if (product.rows[0]) {
// Cache with TTL, fire-and-forget
redis.setex(cacheKey, PRODUCT_TTL, JSON.stringify(product.rows[0])).catch(console.error);
}
return product.rows[0] ?? null;
}
// Cache invalidation on write
export async function updateProduct(productId: string, data: Partial<Product>) {
await db.query(
'UPDATE products SET name=$1, price=$2 WHERE id=$3',
[data.name, data.price, productId]
);
// Invalidate cache immediately
await redis.del(`product:${productId}`);
}
// Batch cache fetch (prevents cache stampede)
export async function getProducts(productIds: string[]) {
const keys = productIds.map(id => `product:${id}`);
const cached = await redis.mget(...keys);
const results: Record<string, Product | null> = {};
const missing: string[] = [];
productIds.forEach((id, idx) => {
if (cached[idx]) {
results[id] = JSON.parse(cached[idx]!);
} else {
missing.push(id);
}
});
if (missing.length > 0) {
const dbResults = await db.query(
'SELECT * FROM products WHERE id = ANY($1)',
[missing]
);
const pipeline = redis.pipeline();
for (const row of dbResults.rows) {
results[row.id] = row;
pipeline.setex(`product:${row.id}`, PRODUCT_TTL, JSON.stringify(row));
}
await pipeline.exec();
}
return results;
}Read/Write Separation for Read-Heavy Workloads
When reads dominate (typical for product listings, dashboards, reports), routing reads to a replica doubles effective database capacity without a schema change.
// src/db/index.ts
import { Pool } from 'pg';
const primaryPool = new Pool({
connectionString: process.env.DATABASE_PRIMARY_URL,
max: 20,
});
const replicaPool = new Pool({
connectionString: process.env.DATABASE_REPLICA_URL,
max: 30, // replicas handle more read load
});
export const db = {
// Writes always go to primary
query: (text: string, params?: unknown[]) =>
primaryPool.query(text, params),
// Reads route to replica (accepts slight replication lag)
queryReplica: (text: string, params?: unknown[]) =>
replicaPool.query(text, params),
};
// Usage
const orders = await db.queryReplica(
'SELECT * FROM orders WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20',
[userId]
);
// Writes always use primary
await db.query(
'INSERT INTO orders (user_id, amount) VALUES ($1, $2)',
[userId, amount]
);Common Mistakes
- Adding Kafka, microservices, or Kubernetes before fixing missing indexes — these solve different problems
- Caching writes (not just reads) — cache is for expensive reads, not writes
- Using TTL-less caches — unbounded caches grow until OOM
- Ignoring connection pool wait time — this is the first sign of a pool bottleneck
- Sharding prematurely — most systems never need sharding; CQRS and read replicas cover 95% of cases
- Measuring after the fact — add
pg_stat_statementsand slow query logging before traffic spikes
Best Practices
- Profile before optimizing:
pg_stat_statements, slow query logs, and APM traces tell you where time actually goes - Fix indexes first, caching second, architecture third
- Size your connection pool based on formula:
(db_max_connections - overhead) / app_instances - Add LIMIT to every query that could return unbounded rows
- Use
EXPLAIN ANALYZEon every query added in a PR - Measure N+1 patterns in integration tests, not production
- Cache at the application layer, not the database layer
Key Takeaways
- At 10x scale, the top bottlenecks are always missing indexes, N+1 queries, and undersized connection pools — not architecture
- A single missing composite index can turn a 5ms query into a 5-second full table scan at 10x row count
- Connection pool size formula:
(max_db_connections - admin_overhead) / num_app_servers - N+1 queries multiply with load: one extra query per item at 1,000 RPM equals 50,000 unnecessary queries per minute at 10x
- Read replicas double effective DB capacity for read-heavy workloads with zero schema changes
- Cache catalog data, user profiles, and config — not transactional data with consistency requirements
pg_stat_statementsand slow query logs are required reading before any 10x growth initiative- Microservices, Kafka, and sharding are solutions to problems that appear after 100x, not 10x
Advertisement