System Design Patterns for Scalability at Senior Level
Real-world architectural patterns I've used to scale Node.js apps from 100 to 100k requests/sec, with code examples and hard-learned lessons.
Last year, I watched our API fall over at 2am because we hit 5000 concurrent users. The strangler pattern saved us, but I wish I'd known about it six months earlier. System design patterns aren't just interview fodder—they're the difference between a service that scales and one that crashes during your product launch.
After six years building full-stack apps with Node.js and TypeScript, I've learned that senior developers need a toolkit of proven patterns. Not because they're fancy, but because reinventing the wheel at 3am during an incident is a terrible idea.
CQRS Pattern for Read-Heavy Workloads
Command Query Responsibility Segregation sounds academic until you're handling 100k reads per second with PostgreSQL. The pattern is simple: separate your write model from your read model.
I implemented this on an e-commerce platform where product searches were killing our primary database. Here's the TypeScript implementation:
// Write model - handles commands
class ProductCommandService {
constructor(private db: Pool) {}
async createProduct(data: CreateProductDTO): Promise<string> {
const result = await this.db.query(
'INSERT INTO products (name, price, inventory) VALUES ($1, $2, $3) RETURNING id',
[data.name, data.price, data.inventory]
);
// Publish event to update read model
await this.eventBus.publish('product.created', {
id: result.rows[0].id,
...data
});
return result.rows[0].id;
}
}
// Read model - optimized for queries
class ProductQueryService {
constructor(private redis: Redis) {}
async searchProducts(term: string): Promise<Product[]> {
// Read from denormalized cache
const cached = await this.redis.get(`search:${term}`);
if (cached) return JSON.parse(cached);
// Fallback to read replica
const products = await this.readReplica.query(
'SELECT * FROM product_search_view WHERE name ILIKE $1',
[`%${term}%`]
);
await this.redis.setex(`search:${term}`, 300, JSON.stringify(products));
return products;
}
}
The gotcha here is eventual consistency. Your read model might lag behind writes by a few hundred milliseconds. In practice, this bit me when users created a product and immediately searched for it—it wasn't there yet. We solved it with optimistic UI updates on the frontend.
Event Sourcing Integration
CQRS pairs beautifully with event sourcing. Instead of storing current state, you store events. I used Kafka for this:
// Event store
interface ProductEvent {
type: 'CREATED' | 'UPDATED' | 'DELETED';
aggregateId: string;
data: any;
timestamp: Date;
}
class EventStore {
async append(event: ProductEvent): Promise<void> {
await this.kafka.send({
topic: 'product-events',
messages: [{ value: JSON.stringify(event) }]
});
}
async rebuild(aggregateId: string): Promise<Product> {
const events = await this.fetchEvents(aggregateId);
return events.reduce((product, event) => {
return this.applyEvent(product, event);
}, {} as Product);
}
}
Honestly, event sourcing is overkill for most apps. But for audit trails and complex domains, it's gold.
Circuit Breaker Pattern
This pattern saved my bacon when a third-party payment API started timing out. Instead of cascading failures, we failed fast and gracefully.
I use the `opossum` library in Node.js, but here's a simplified implementation to show the concept:
class CircuitBreaker {
private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
private failureCount = 0;
private lastFailureTime?: Date;
constructor(
private threshold: number = 5,
private timeout: number = 60000
) {}
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'OPEN') {
if (Date.now() - this.lastFailureTime!.getTime() > this.timeout) {
this.state = 'HALF_OPEN';
} else {
throw new Error('Circuit breaker is OPEN');
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess(): void {
this.failureCount = 0;
this.state = 'CLOSED';
}
private onFailure(): void {
this.failureCount++;
this.lastFailureTime = new Date();
if (this.failureCount >= this.threshold) {
this.state = 'OPEN';
}
}
}
// Usage
const breaker = new CircuitBreaker(5, 60000);
app.post('/checkout', async (req, res) => {
try {
const payment = await breaker.execute(() =>
paymentAPI.charge(req.body)
);
res.json({ success: true, payment });
} catch (error) {
// Fallback: queue for later processing
await queue.add('pending-payments', req.body);
res.json({ success: true, status: 'pending' });
}
});
The pattern prevents your system from hammering a failing service. We deployed this on GCP Cloud Run and saw our error rates drop by 80% during downstream outages.
Database Sharding Strategies
When MongoDB collections hit 100M documents, queries slow to a crawl. Sharding distributes data across multiple databases.
Horizontal Sharding by Key
I've used geographic sharding for a multi-tenant SaaS. Each customer's data lives in a region-specific database:
class ShardedRepository {
private shards: Map<string, Pool> = new Map();
constructor() {
this.shards.set('us-east', new Pool({ connectionString: process.env.DB_US_EAST }));
this.shards.set('eu-west', new Pool({ connectionString: process.env.DB_EU_WEST }));
this.shards.set('ap-south', new Pool({ connectionString: process.env.DB_AP_SOUTH }));
}
private getShard(userId: string): Pool {
const region = this.getUserRegion(userId);
return this.shards.get(region)!;
}
async getUserData(userId: string): Promise<User> {
const shard = this.getShard(userId);
const result = await shard.query(
'SELECT * FROM users WHERE id = $1',
[userId]
);
return result.rows[0];
}
}
The tradeoff: cross-shard queries become painful. We had to denormalize data to avoid JOINs across databases. Also, rebalancing shards is a nightmare—pick your sharding key wisely upfront.
Saga Pattern for Distributed Transactions
Distributed transactions across microservices are hard. The Saga pattern breaks them into local transactions with compensating actions.
Here's an order processing saga I built with NestJS and AWS SQS:
class OrderSaga {
async execute(order: Order): Promise<void> {
const steps = [
{ action: () => this.reserveInventory(order), compensate: () => this.releaseInventory(order) },
{ action: () => this.chargePayment(order), compensate: () => this.refundPayment(order) },
{ action: () => this.createShipment(order), compensate: () => this.cancelShipment(order) }
];
const completed: number[] = [];
try {
for (let i = 0; i < steps.length; i++) {
await steps[i].action();
completed.push(i);
}
} catch (error) {
// Rollback completed steps in reverse
for (let i = completed.length - 1; i >= 0; i--) {
try {
await steps[completed[i]].compensate();
} catch (compensateError) {
// Log and alert - manual intervention needed
await this.alertOps(compensateError);
}
}
throw error;
}
}
private async reserveInventory(order: Order): Promise<void> {
await this.sqs.sendMessage({
QueueUrl: process.env.INVENTORY_QUEUE,
MessageBody: JSON.stringify({ action: 'reserve', order })
});
}
}
Sagas aren't perfect. Compensating transactions might fail, leaving you in an inconsistent state. We added idempotency keys to every operation and an ops dashboard to catch stuck sagas.
Bulkhead Pattern
Named after ship compartments, this pattern isolates resources so one failure doesn't sink the whole ship. I use separate connection pools for critical vs non-critical operations:
class DatabasePools {
// Critical operations get dedicated pool
private criticalPool = new Pool({
max: 20,
connectionString: process.env.DATABASE_URL
});
// Analytics queries use separate pool
private analyticsPool = new Pool({
max: 5,
connectionString: process.env.DATABASE_URL
});
async executeTransaction(userId: string, amount: number): Promise<void> {
// Uses critical pool - always available
await this.criticalPool.query(
'UPDATE accounts SET balance = balance - $1 WHERE user_id = $2',
[amount, userId]
);
}
async generateReport(): Promise<Report> {
// Uses separate pool - won't affect transactions
const result = await this.analyticsPool.query(
'SELECT * FROM daily_stats WHERE date > NOW() - INTERVAL \'30 days\''
);
return this.formatReport(result.rows);
}
}
On AWS Lambda, I do the same with reserved concurrency. Critical endpoints get 80% of capacity reserved, ensuring they're never starved.
Strangler Fig Pattern
Migrating monoliths to microservices is risky. The strangler pattern gradually replaces old code with new services.
We used this to migrate a legacy Express app to NestJS microservices. Here's the proxy router:
// Reverse proxy that routes to old or new service
app.use('/api/*', async (req, res, next) => {
const features = await this.featureFlags.get(req.user.id);
if (features.useNewOrderService && req.path.startsWith('/api/orders')) {
// Route to new NestJS service
return httpProxy.web(req, res, {
target: 'http://orders-service:3001'
});
}
// Fall through to legacy Express handlers
next();
});
We used feature flags to roll out the new service to 1%, then 10%, then 100% of users over three months. Zero downtime. This pattern is unglamorous but it works.
Key Takeaways
System design patterns for scalability aren't theoretical—they're tools you reach for when your app grows beyond a single server. Here's what I've learned:
- CQRS works great for read-heavy apps, but embrace eventual consistency
- Circuit breakers prevent cascading failures—implement them before you need them
- Database sharding is powerful but complex—consider read replicas and caching first
- Sagas handle distributed transactions, but you need idempotency and monitoring
- Bulkheads isolate failures—separate connection pools are low-hanging fruit
- Strangler pattern enables safe migrations—feature flags are your friend
These patterns have tradeoffs. CQRS adds complexity. Sharding makes cross-database queries hard. Sagas can leave you in inconsistent states. But when you're serving millions of requests, these patterns are how senior developers build systems that don't fall over.
Start simple. Add patterns as you need them. And always measure before optimizing—premature distribution is just as bad as premature optimization.
Related Articles
Building a Video Streaming Platform: Architecture & Code
A deep dive into architecting and building a production-ready video streaming platform using Node.js, AWS, and adaptive bitrate streaming.
Serverless Scaling for High-Traffic Video Streaming
When a popular title suddenly lands on a streaming service, demand spikes hard. Let's explore how serverless architectures and robust CDNs help us build a streaming platform that scales to handle it.
OpenAI APIs: A Full-Stack Dev's Deep Dive
Explore practical applications of OpenAI's APIs for full-stack developers. Learn how to integrate them into your JavaScript/TypeScript projects with real-world examples and best practices.
