Back to Blog
Full Stack7 min read

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.

Jay Salot

Jay Salot

Senior Full Stack AI Engineer

July 20, 2026 · 7 min read

Share
Laptop with code editor open

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.

#system-design#scalability#architecture#Node.js#patterns
Share

Related Articles