System Design Interview Mastery Guide

This guide covers backend systems, frontend architecture, and full-stack integration for system design interviews. Each topic includes architecture diagrams, TypeScript implementations, capacity estimations, and interview guidance. The questions progress from foundational concepts to advanced distributed systems patterns.

PrerequisitesDescription
Networking basicsHTTP, TCP/IP, DNS
Database fundamentalsACID, indexing, joins
Data structures and algorithmsHash maps, trees, graphs
Frontend frameworksReact, Vue, or Angular
RESTful API conceptsHTTP verbs, status codes, resource modeling
Cloud services basicsAWS, GCP, or Azure fundamentals

Table of contents

Part 1: Foundational System Design (Q1-Q10)

#TopicDifficulty
1System Design Fundamentals — CAP theorem, scalability, availabilityBeginner
2Load Balancing and Reverse Proxy — Distribution strategies, health checksBeginner
3Caching Strategies — Redis, CDN, browser cache, cache invalidationIntermediate
4Database Design — SQL vs NoSQL, sharding, replication, indexingIntermediate
5API Design — REST, GraphQL, gRPC, versioning, rate limitingIntermediate
6Message Queues and Event Streaming — RabbitMQ, Kafka, async processingIntermediate
7Microservices Architecture — Service decomposition, communication patternsAdvanced
8Authentication and Authorization — OAuth2, JWT, SSO, RBACIntermediate
9Monitoring and Observability — Logging, metrics, tracing, alertingIntermediate
10Content Delivery Network (CDN) — Edge caching, global distributionBeginner

Part 2: Frontend Architecture and System Design (Q11-Q15)

#TopicDifficulty
11Frontend Architecture Patterns — MVC, Flux, Redux, Component architectureIntermediate
12State Management at Scale — Global state, server state, local stateIntermediate
13Frontend Performance Optimization — Code splitting, lazy loading, bundlingIntermediate
14Real-time Frontend Systems — WebSockets, SSE, polling strategiesIntermediate
15Micro-Frontends — Module federation, component sharing, deploymentAdvanced

Part 3: Full-Stack System Design Problems (Q16-Q25)

#TopicDifficulty
16Design URL Shortener — End-to-end with frontendIntermediate
17Design Social Media Feed — Twitter/Instagram timeline with UIAdvanced
18Design E-Commerce Platform — Product catalog, cart, checkout flowAdvanced
19Design Video Streaming Service — YouTube/Netflix with playerAdvanced
20Design Real-time Collaboration Tool — Google Docs/Figma with CRDTAdvanced
21Design Ride-Sharing App — Uber/Lyft with live trackingAdvanced
22Design Chat Application — WhatsApp/Slack with message syncAdvanced
23Design Search Autocomplete — Frontend + backend with rankingIntermediate
24Design Notification System — Multi-channel with preferences UIAdvanced
25Design Analytics Dashboard — Real-time data visualizationAdvanced

Part 4: Advanced System Design (Q26-Q30)

#TopicDifficulty
26Distributed Systems Patterns — Saga, CQRS, Event SourcingAdvanced
27Data Consistency and Replication — Eventual consistency, conflict resolutionAdvanced
28High Availability and Disaster Recovery — Failover, backup strategiesAdvanced
29Security Architecture — DDoS protection, encryption, secure frontendAdvanced
30Global Scale Systems — Multi-region, geo-distribution, data sovereigntyAdvanced

Part 5: Behavioral Questions (Q31-Q35)

#Topic
31System Design Trade-offs — STAR format examples
32Scaling Challenges — Real production incidents
33Cross-Team Collaboration — Frontend-backend alignment
34Technical Debt — Architectural decisions and refactoring
35Performance Optimization Stories — End-to-end improvements

Part 6: Quick Fire Round (Q36-Q45)

Rapid-fire system design concepts and comparisons.

Part 7: Interview Success Resources

System design frameworks (USCALES, RADIO), company-specific focus areas, essential resources, practice platforms, and preparation timeline.


Study strategy

By experience level

LevelFocus areasStudy timeKey goals
Junior (0-2 yrs)Q1-Q5, Q10, Q16, Q236-8 weeksFundamentals, simple diagrams, basic trade-offs
Mid-Level (2-5 yrs)Q1-Q15, Q16-Q18, Q23-Q248-10 weeksDeep dive all foundations + frontend architecture, capacity estimates
Senior (5+ yrs)All Q1-Q454-6 weeksDistributed systems, multi-region, security, driving ambiguity

Preparation timeline

WeeksFocus
1-2CAP theorem, scalability, load balancing, caching, database fundamentals
3-4API design (REST, GraphQL), message queues, authentication, monitoring
5-6Component architecture, state management, performance optimization, real-time
7-8Full-stack problems (2-3 per week), end-to-end diagrams, capacity estimates
9-10Distributed systems, multi-region, mock interviews, behavioral stories

USCALES framework

Use this framework for every system design problem:

StepMeaningActions
UUnderstand requirementsFunctional + non-functional requirements
SScope the problemWhat is in/out of scope
CCapacity estimationUsers, requests, storage
AAPI designEndpoints, data models
LLow-level designComponent details
EExtensionsHow to scale, future features
SSecurity and monitoringAuth, logging, alerting

Note: Always include both frontend and backend in your designs. Show data flow in both directions. Label all components, number the steps, and include databases, caches, and queues.

Capacity estimation reference

MetricHow to estimate
DAUDaily Active Users
QPSQueries per second = DAU x actions/user / 86400
Peak QPS~3x average QPS
Storageitems/day x item_size x retention_years x 365
BandwidthQPS x average_response_size
Cache sizeWorking set (80/20 rule) x item_size

Q1: System Design Fundamentals

System design fundamentals cover the core concepts needed for every architecture discussion: the CAP theorem, horizontal vs vertical scaling, latency vs throughput trade-offs, availability calculations, and designing for failure.

Syntax

ConceptSummary
CAP TheoremConsistency, Availability, Partition Tolerance — pick two
Horizontal scalingAdd more machines
Vertical scalingAdd more resources to one machine
LatencyTime to complete a single request
ThroughputRequests processed per unit time
AvailabilityUptime percentage, measured in "nines"

Description

CAP Theorem

In a distributed system experiencing a network partition (P), you must choose between consistency (C) and availability (A). Partition tolerance is mandatory in any distributed system, so the real choice is CP or AP.

System typeBehavior during partitionExamples
CPRejects requests to stay consistentMongoDB, HBase, Redis
APServes requests with potentially stale dataCassandra, DynamoDB, CouchDB
CANot partition-tolerant (single-node only)Traditional RDBMS

Note: Most modern systems use eventual consistency (AP) with tunable consistency levels. Banking systems choose CP; social media systems choose AP.

Horizontal vs vertical scaling

AspectVertical (scale up)Horizontal (scale out)
ApproachBigger machineMore machines
LimitHardware ceilingNearly infinite
Fault toleranceSingle point of failureRedundancy built in
ComplexitySimple, no code changesLoad balancer, consistency challenges
CostExpensive at high endCost-effective (commodity hardware)
DowntimeRequired for upgradesRolling updates, zero downtime
Best forDatabases, legacy systemsWeb servers, APIs, microservices

Latency vs throughput

PriorityStrategyUse case
Low latencyCache aggressively, CDN, read replicas, optimized queriesReal-time gaming, trading platforms
High throughputBatch processing, async operations, message queues, horizontal scalingLog processing, analytics, batch jobs
BothExpensive — caching, CDN, many serversLarge-scale production systems

Availability

NinesUptime %Downtime per yearDowntime per month
Two nines99%87h 36m7h 18m
Three nines99.9%8h 45m43m
Four nines99.99%52m4m 23s
Five nines99.999%5m 15s26s

Examples

CP system — banking transaction with locks:

interface BankTransaction {
  id: string;
  from: string;
  to: string;
  amount: number;
  timestamp: number;
}

class ConsistentBankingSystem {
  private balances = new Map<string, number>();
  private locks = new Map<string, boolean>();

  async transfer(from: string, to: string, amount: number): Promise<boolean> {
    await this.acquireLocks([from, to]);
    try {
      const balance = this.balances.get(from) || 0;
      if (balance < amount) return false; // Consistent — reject insufficient funds
      this.balances.set(from, balance - amount);
      this.balances.set(to, (this.balances.get(to) || 0) + amount);
      return true;
    } finally {
      this.releaseLocks([from, to]);
    }
  }

  private async acquireLocks(accounts: string[]): Promise<void> {
    for (const acct of accounts.sort()) { // Sort to prevent deadlock
      while (this.locks.get(acct)) {
        await new Promise(r => setTimeout(r, 10));
      }
      this.locks.set(acct, true);
    }
  }

  private releaseLocks(accounts: string[]): void {
    accounts.forEach(a => this.locks.delete(a));
  }
}

AP system — social media likes (eventual consistency):

class AvailableSocialSystem {
  private likes = new Map<string, Set<string>>(); // postId -> userIds

  async like(userId: string, postId: string): Promise<void> {
    // Always available — accept write immediately (AP)
    if (!this.likes.has(postId)) this.likes.set(postId, new Set());
    this.likes.get(postId)!.add(userId);

    // Async replication — don't block the client
    this.replicateAsync({ userId, postId, timestamp: Date.now() });
  }

  getLikeCount(postId: string): number {
    return this.likes.get(postId)?.size || 0; // May return stale data
  }

  private async replicateAsync(like: {
    userId: string; postId: string; timestamp: number;
  }): Promise<void> {
    setTimeout(() => {
      console.log(`Replicated to nodes: ${like.postId}`);
    }, 0);
  }
}

Load balancer with round-robin:

class LoadBalancer {
  private servers: string[] = [];
  private currentIndex = 0;

  addServer(url: string): void {
    this.servers.push(url);
  }

  getNextServer(): string {
    const server = this.servers[this.currentIndex];
    this.currentIndex = (this.currentIndex + 1) % this.servers.length;
    return server;
  }

  async handleRequest(request: any): Promise<any> {
    const server = this.getNextServer();
    return fetch(server, {
      method: 'POST',
      body: JSON.stringify(request),
    });
  }
}

const lb = new LoadBalancer();
lb.addServer('http://server1:3000');
lb.addServer('http://server2:3000');
lb.addServer('http://server3:3000');

Latency percentile tracker:

class PerformanceMetrics {
  private requestTimes: number[] = [];

  async measure<T>(op: () => Promise<T>): Promise<{ result: T; latency: number }> {
    const start = performance.now();
    const result = await op();
    const latency = performance.now() - start;
    this.requestTimes.push(latency);
    return { result, latency };
  }

  getPercentile(p: number): number {
    const sorted = [...this.requestTimes].sort((a, b) => a - b);
    const idx = Math.floor((p / 100) * sorted.length);
    return sorted[idx] || 0;
  }

  getP50(): number { return this.getPercentile(50); }
  getP95(): number { return this.getPercentile(95); }
  getP99(): number { return this.getPercentile(99); }
}

Availability calculator:

class AvailabilityCalculator {
  getDowntimeAllowance(nines: number) {
    const percent = 100 - parseFloat('9'.repeat(nines));
    const yearlyMin = 365.25 * 24 * 60 * (percent / 100);
    const monthlyMin = 30 * 24 * 60 * (percent / 100);
    return {
      yearly: `${Math.floor(yearlyMin / 60)}h ${Math.floor(yearlyMin % 60)}m`,
      monthly: `${Math.floor(monthlyMin / 60)}h ${Math.floor(monthlyMin % 60)}m`,
    };
  }
}

High availability with failover:

class HighAvailabilitySystem {
  private primaryDB = 'db-primary';
  private replicas = ['db-replica-1', 'db-replica-2'];

  async executeQuery(query: string): Promise<any> {
    if (await this.checkHealth(this.primaryDB)) {
      return this.queryDatabase(this.primaryDB, query);
    }
    // Failover to replicas
    for (const replica of this.replicas) {
      if (await this.checkHealth(replica)) {
        console.warn(`Primary down, using replica: ${replica}`);
        return this.queryDatabase(replica, query);
      }
    }
    throw new Error('All databases unavailable');
  }

  private async checkHealth(service: string): Promise<boolean> {
    try {
      await fetch(`http://${service}/health`, {
        signal: AbortSignal.timeout(5000),
      });
      return true;
    } catch { return false; }
  }

  private async queryDatabase(db: string, query: string) {
    return { success: true, data: [] };
  }
}

Capacity estimation example — Twitter:

DAU: 200 million

Writes:
- Tweets/day: 200M * 2 = 400M
- TPS: 400M / 86400 ≈ 4.6K
- Peak (3x): ~14K TPS

Reads:
- Timeline views: 200M * 50 = 10B/day
- RPS: 10B / 86400 ≈ 115K
- Peak: ~350K RPS

Storage (5 years):
- Tweets: 400M/day * 365 * 5 = 730B tweets
- Size: 730B * 1KB = 730TB

Bandwidth:
- Write: 4.6K TPS * 1KB = 4.6 MB/s
- Read: 115K RPS * 1KB = 115 MB/s

Comparison

PrincipleGuideline
Design for failureAssume everything fails. Use retries with exponential backoff, circuit breakers, and fallback mechanisms
Loose couplingServices should be independent. Use message queues for async communication and versioned API contracts
Stateless designStore state in databases/caches. Enables horizontal scaling and easy server replacement

Interview guidance

How to explain it:

"System design is about creating scalable, reliable, and maintainable architectures. The core concepts are CAP theorem, horizontal vs vertical scaling, latency vs throughput trade-offs, and designing for failure. Modern systems balance these trade-offs based on business requirements."

Common follow-ups:

QuestionKey points
How do you design for high availability?Multi-region active-active deployment, health checks every 30s, automatic failover via load balancer, database replication with read replicas, circuit breakers, comprehensive monitoring, target 99.99% uptime
CAP theorem in practice?Network partitions are inevitable (P is mandatory). Banking chooses CP — better to reject than show wrong balance. Social media chooses AP — better to show stale data than be unavailable. Most systems use eventual consistency

Difficulty: Beginner


Q2: Load Balancing and Reverse Proxy

A load balancer distributes incoming traffic across multiple servers so no single server is overwhelmed. A reverse proxy sits in front of backend servers to provide caching, SSL termination, rate limiting, and request routing.

Syntax

ComponentRole
Load balancerDistributes requests to healthy backend servers
Reverse proxyAdds caching, SSL termination, compression, rate limiting
Health checkPeriodic probe to verify server is responsive
Layer 4 (L4)Routes by IP/port (TCP/UDP level) — fast, no content inspection
Layer 7 (L7)Routes by URL, headers, cookies (HTTP level) — smart routing
AlgorithmMechanismBest for
Round RobinRotate through servers sequentiallyStateless apps, equal servers
Least ConnectionsPick server with fewest active connectionsLong-lived connections
IP HashHash client IP to select serverSession affinity
Weighted Round RobinServers with higher weight get more requestsHeterogeneous servers

Description

Load balancers are deployed at the network edge between clients and backend servers. They perform health checks (typically every 30 seconds) and automatically remove unhealthy servers from rotation. When a failed server recovers, it is re-added.

A reverse proxy is typically the same component (Nginx, HAProxy) and handles:

  • SSL termination — decrypts HTTPS, forwards HTTP internally
  • Response caching — caches GET responses to reduce backend load
  • Compression — gzip/brotli responses before sending to clients
  • Rate limiting — rejects excessive requests per client IP

Note: In production, Layer 7 is used more often than Layer 4 because it enables content-based routing, SSL termination, and caching. Layer 4 is preferred when raw throughput matters and no HTTP inspection is needed.

Examples

Load balancer implementation:

type Algorithm = 'round-robin' | 'least-connections' | 'ip-hash' | 'weighted';

interface Server {
  id: string;
  url: string;
  weight: number;
  activeConnections: number;
  healthy: boolean;
}

class LoadBalancer {
  private servers = new Map<string, Server>();
  private currentIndex = 0;
  private algorithm: Algorithm;

  constructor(algorithm: Algorithm = 'round-robin') {
    this.algorithm = algorithm;
    this.startHealthChecks();
  }

  addServer(id: string, url: string, weight = 1): void {
    this.servers.set(id, {
      id, url, weight, activeConnections: 0, healthy: true,
    });
  }

  private selectServer(clientIp: string): Server | null {
    const healthy = [...this.servers.values()].filter(s => s.healthy);
    if (healthy.length === 0) return null;

    switch (this.algorithm) {
      case 'round-robin': return this.roundRobin(healthy);
      case 'least-connections': return this.leastConnections(healthy);
      case 'ip-hash': return this.ipHash(clientIp, healthy);
      case 'weighted': return this.weightedRR(healthy);
      default: return healthy[0];
    }
  }

Algorithm implementations:

  // continued from LoadBalancer class

  private roundRobin(servers: Server[]): Server {
    const s = servers[this.currentIndex % servers.length];
    this.currentIndex++;
    return s;
  }

  private leastConnections(servers: Server[]): Server {
    return servers.reduce((prev, curr) =>
      curr.activeConnections < prev.activeConnections ? curr : prev
    );
  }

  private ipHash(ip: string, servers: Server[]): Server {
    let hash = 0;
    for (let i = 0; i < ip.length; i++) {
      hash = ((hash << 5) - hash) + ip.charCodeAt(i);
      hash = hash & hash;
    }
    return servers[Math.abs(hash) % servers.length];
  }

  private weightedRR(servers: Server[]): Server {
    const total = servers.reduce((sum, s) => sum + s.weight, 0);
    let rand = Math.floor(Math.random() * total);
    for (const s of servers) {
      rand -= s.weight;
      if (rand < 0) return s;
    }
    return servers[0];
  }

Request routing with health checks:

  // continued from LoadBalancer class

  async handleRequest(clientIp: string, request: any): Promise<any> {
    const server = this.selectServer(clientIp);
    if (!server) throw new Error('No healthy servers available');

    server.activeConnections++;
    try {
      const res = await fetch(server.url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-Forwarded-For': clientIp,
        },
        body: JSON.stringify(request),
      });
      return await res.json();
    } finally {
      server.activeConnections--;
    }
  }

  private startHealthChecks(): void {
    setInterval(async () => {
      for (const server of this.servers.values()) {
        try {
          const res = await fetch(`${server.url}/health`, {
            signal: AbortSignal.timeout(5000),
          });
          server.healthy = res.ok;
        } catch {
          server.healthy = false;
        }
      }
    }, 30000); // Every 30 seconds
  }
}

Reverse proxy with caching and rate limiting:

class ReverseProxy {
  private cache = new Map<string, { data: any; timestamp: number; ttl: number }>();
  private rateLimits = new Map<string, number[]>();
  private loadBalancer: LoadBalancer;

  constructor() {
    this.loadBalancer = new LoadBalancer('least-connections');
  }

  async handleRequest(
    method: string, path: string, clientIp: string, body?: any
  ): Promise<any> {
    // Cache check for GET requests
    if (method === 'GET') {
      const cached = this.checkCache(path);
      if (cached) return cached;
    }

    // Rate limiting
    if (!this.checkRateLimit(clientIp)) {
      throw new Error('Rate limit exceeded');
    }

    // Forward to backend
    const response = await this.loadBalancer.handleRequest(clientIp, {
      method, path, body,
    });

    // Cache successful GETs
    if (method === 'GET' && response) {
      this.setCache(path, response, 300000); // 5 min TTL
    }
    return response;
  }

Rate limiting logic:

  // continued from ReverseProxy class

  private checkRateLimit(clientIp: string): boolean {
    const now = Date.now();
    const window = 60000; // 1 minute
    const maxRequests = 100;

    let requests = this.rateLimits.get(clientIp) || [];
    requests = requests.filter(t => now - t < window);

    if (requests.length >= maxRequests) return false;
    requests.push(now);
    this.rateLimits.set(clientIp, requests);
    return true;
  }

  private checkCache(key: string): any | null {
    const entry = this.cache.get(key);
    if (!entry) return null;
    if (Date.now() - entry.timestamp > entry.ttl) {
      this.cache.delete(key);
      return null;
    }
    return entry.data;
  }

  private setCache(key: string, data: any, ttl: number): void {
    this.cache.set(key, { data, timestamp: Date.now(), ttl });
    if (this.cache.size > 10000) {
      this.cache.delete(this.cache.keys().next().value); // LRU eviction
    }
  }
}

Frontend retry logic with exponential backoff:

class APIClient {
  private baseURL: string;
  private maxRetries = 3;

  constructor(baseURL: string) {
    this.baseURL = baseURL; // Load balancer URL
  }

  async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
    let lastError: Error;

    for (let attempt = 0; attempt < this.maxRetries; attempt++) {
      try {
        const res = await fetch(`${this.baseURL}${endpoint}`, {
          ...options,
          headers: { 'Content-Type': 'application/json', ...options.headers },
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return await res.json();
      } catch (error) {
        lastError = error as Error;
        const delay = Math.pow(2, attempt) * 100; // 100ms, 200ms, 400ms
        await new Promise(r => setTimeout(r, delay));
      }
    }
    throw new Error(`Failed after ${this.maxRetries} retries: ${lastError!.message}`);
  }
}

Production Nginx configuration:

upstream backend {
    least_conn;
    server api1.example.com:3000 weight=5 max_fails=3 fail_timeout=30s;
    server api2.example.com:3000 weight=3 max_fails=3 fail_timeout=30s;
    server api3.example.com:3000 weight=2 max_fails=3 fail_timeout=30s;
}

server {
    listen 443 ssl http2;
    server_name api.example.com;

    ssl_certificate     /etc/ssl/certs/example.com.crt;
    ssl_certificate_key /etc/ssl/private/example.com.key;

    proxy_cache my_cache;
    proxy_cache_valid 200 5m;

    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=100r/m;
    limit_req zone=api_limit burst=20 nodelay;

    location / {
        proxy_pass http://backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_connect_timeout 5s;
        proxy_read_timeout 60s;
    }
}

Architecture diagram:

Users / CDN
     │ HTTPS (443)
     ▼
┌──────────────────────────┐
│  Reverse Proxy / LB      │  SSL Termination
│  (Nginx / HAProxy)       │  Caching, Rate Limiting
│  Health checks, Compression│
└────────────┬─────────────┘
             │ HTTP (internal)
    ┌────────┼────────┬────────┐
    ▼        ▼        ▼        ▼
 Server 1  Server 2  Server 3  Server 4
 (Node.js) (Node.js) (Node.js) (Node.js)
    └────────┴────────┴────────┘
             │
             ▼
      Database (replicated)

Comparison

AlgorithmProsCons
Round RobinSimple, fair distributionIgnores actual server load
Least ConnectionsBalances real loadMore complex tracking
IP HashSticky sessionsUneven distribution
WeightedHonors server capacityRequires configuration tuning
AspectLayer 4 (Transport)Layer 7 (Application)
LevelTCP/UDPHTTP/HTTPS
SpeedFaster — no content inspectionSlower — parses headers/body
RoutingIP and port basedURL, headers, cookies, content
FeaturesBasic forwardingSSL termination, caching, routing
Use caseRaw throughputSmart routing, HTTP features

Interview guidance

How to explain it:

"A load balancer distributes incoming traffic across multiple servers to prevent overload. It performs health checks to route only to healthy servers. A reverse proxy adds caching, SSL termination, rate limiting, and compression. Algorithms include round-robin, least connections, IP hash, and weighted round-robin."

Common follow-ups:

QuestionKey points
How do you handle server failures?Health checks every 30s, automatic removal from rotation, circuit breakers, frontend retry with exponential backoff, auto re-add on recovery
Layer 4 vs Layer 7?L4 works at TCP level — fast, no inspection. L7 works at HTTP level — enables content-based routing, SSL termination, caching. Use L4 for throughput, L7 for HTTP features
Session affinity trade-offs?IP hash or cookie-based sticky sessions keep user on same server. Downside: uneven distribution, harder scaling. Prefer stateless design with external session store (Redis)

Difficulty: Beginner


Q3: Caching Strategies

Caching stores frequently accessed data in fast storage to reduce latency and database load. A multi-layer caching strategy uses browser cache, CDN, application cache (Redis/Memcached), and database query cache. Cache invalidation is the hardest problem — use TTL, write-through, or event-based invalidation.

Syntax

Cache layerLatencyWhat to cacheTTL guidance
Browser (memory/disk)~0-5 msStatic assets, preferencesStatic: 1 year (versioned URLs). Dynamic: 1-5 min
CDN edge~50 msJS, CSS, images, fontsCache-Control: public, max-age=31536000
Application (Redis)~1-2 msHot data, sessionsHot data: 1-5 min. Sessions: 30 min
Database query cache~5-10 msFrequent queriesBuilt-in, varies by engine
Database (primary)~20-50 msSource of truthN/A
Invalidation strategyMechanismBest for
TTL (Time To Live)Auto-expire after durationNews feeds, public content
Write-throughUpdate cache on every writeUser profiles, settings
Event-basedInvalidate on specific eventsRelated data updates
Cache tagsGroup and bulk-invalidateCategory pages, lists

Description

Caching follows a hierarchy — requests hit the fastest layer first and fall through to slower layers on cache misses. The cache-aside (lazy loading) pattern fetches from cache first, loads from database on miss, then populates cache.

Warning: Cache stampede (thundering herd) occurs when many requests hit the same expired key simultaneously. All requests bypass cache and hit the database. Prevent this by coalescing concurrent requests — only one request loads the data while others wait for the result.

Note: Cache sizing follows the 80/20 rule: 20% of items account for 80% of accesses. Cache the working set, not the entire dataset.

Examples

Browser cache manager:

class BrowserCacheManager {
  static registerServiceWorker(): void {
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.register('/sw.js');
    }
  }

  async getCachedResponse(url: string): Promise<Response | null> {
    const cache = await caches.open('api-cache-v1');
    const cached = await cache.match(url);
    if (!cached) return null;

    const age = Date.now() - new Date(cached.headers.get('date') || '').getTime();
    if (age < 5 * 60 * 1000) return cached; // 5 min max age
    return null;
  }

  setPreference(key: string, value: any): void {
    localStorage.setItem(key, JSON.stringify({
      value, timestamp: Date.now(),
    }));
  }

  getPreference<T>(key: string, maxAge = 86400000): T | null {
    const item = localStorage.getItem(key);
    if (!item) return null;
    const { value, timestamp } = JSON.parse(item);
    if (Date.now() - timestamp > maxAge) {
      localStorage.removeItem(key);
      return null;
    }
    return value;
  }
}

Application-level cache with TTL and tags:

interface CacheEntry<T> {
  value: T;
  expiresAt: number;
  tags: string[];
}

class ApplicationCache {
  private cache = new Map<string, CacheEntry<any>>();
  private stats = { hits: 0, misses: 0, sets: 0, evictions: 0 };

  set<T>(key: string, value: T, ttlMs: number, tags: string[] = []): void {
    this.cache.set(key, { value, expiresAt: Date.now() + ttlMs, tags });
    this.stats.sets++;
    if (this.cache.size > 10000) {
      this.cache.delete(this.cache.keys().next().value); // LRU
      this.stats.evictions++;
    }
  }

  get<T>(key: string): T | null {
    const entry = this.cache.get(key);
    if (!entry) { this.stats.misses++; return null; }
    if (Date.now() > entry.expiresAt) {
      this.cache.delete(key);
      this.stats.misses++;
      return null;
    }
    this.stats.hits++;
    return entry.value;
  }

Cache-aside pattern and tag invalidation:

  // continued from ApplicationCache class

  async getOrLoad<T>(
    key: string, loader: () => Promise<T>, ttlMs: number, tags: string[] = []
  ): Promise<T> {
    const cached = this.get<T>(key);
    if (cached !== null) return cached;
    const value = await loader();
    this.set(key, value, ttlMs, tags);
    return value;
  }

  invalidateByTag(tag: string): void {
    for (const [key, entry] of this.cache.entries()) {
      if (entry.tags.includes(tag)) this.cache.delete(key);
    }
  }

  delete(key: string): void { this.cache.delete(key); }

  getStats() {
    const rate = this.stats.hits / (this.stats.hits + this.stats.misses) || 0;
    return { ...this.stats, size: this.cache.size, hitRate: (rate * 100).toFixed(2) + '%' };
  }
}

Write-through cache:

class WriteThroughCache {
  constructor(private cache: ApplicationCache, private database: any) {}

  async set(key: string, value: any): Promise<void> {
    await this.database.save(key, value);          // Write to DB first
    this.cache.set(key, value, 3600000, ['user']); // Then update cache
  }

  async get(key: string): Promise<any> {
    return this.cache.getOrLoad(
      key,
      () => this.database.load(key),  // Fallback to DB on miss
      3600000,
      ['user']
    );
  }
}

Stampede prevention (request coalescing):

class StampedeProtection {
  private loading = new Map<string, Promise<any>>();

  constructor(private cache: ApplicationCache) {}

  async get<T>(key: string, loader: () => Promise<T>, ttlMs: number): Promise<T> {
    const cached = this.cache.get<T>(key);
    if (cached !== null) return cached;

    // Coalesce: reuse in-flight request
    const inflight = this.loading.get(key);
    if (inflight) return inflight;

    const promise = loader().then(value => {
      this.cache.set(key, value, ttlMs);
      this.loading.delete(key);
      return value;
    }).catch(err => {
      this.loading.delete(key);
      throw err;
    });

    this.loading.set(key, promise);
    return promise;
  }
}

React component with multi-layer caching:

import { useQuery } from '@tanstack/react-query';

function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading } = useQuery({
    queryKey: ['user', userId],
    queryFn: async () => {
      // Layer 1: browser sessionStorage
      const cached = sessionStorage.getItem(`user:${userId}`);
      if (cached) {
        const { value, timestamp } = JSON.parse(cached);
        if (Date.now() - timestamp < 60000) return value;
      }
      // Layer 2: fetch from server (which has Redis cache)
      const res = await fetch(`/api/users/${userId}`);
      const data = await res.json();
      sessionStorage.setItem(`user:${userId}`, JSON.stringify({
        value: data, timestamp: Date.now(),
      }));
      return data;
    },
    staleTime: 60000,   // Fresh for 1 min
    gcTime: 300000,      // Keep in memory 5 min
  });

  if (isLoading) return <div>Loading...</div>;
  return <div><h1>{data.name}</h1></div>;
}

Service Worker for offline-first caching:

const CACHE_NAME = 'app-v1';
const urlsToCache = ['/', '/static/js/main.js', '/static/css/main.css'];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => cache.addAll(urlsToCache))
  );
});

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then(response => {
      if (response) return response; // Cache hit
      return fetch(event.request.clone()).then(res => {
        if (!res || res.status !== 200 || res.type !== 'basic') return res;
        const toCache = res.clone();
        caches.open(CACHE_NAME).then(cache => cache.put(event.request, toCache));
        return res;
      });
    })
  );
});

Cache sizing estimation:

E-commerce product catalog:

Active products:    100,000
Average size:       2 KB (JSON)
Working set (80/20): 20,000 products
Cache needed:       20,000 * 2 KB = 40 MB

User sessions:      10,000 * 5 KB = 50 MB
Popular searches:   1,000 * 10 KB = 10 MB
Total Redis memory: 100 MB (with 2x headroom = 200 MB)

Eviction policy:    allkeys-lru (Least Recently Used)

Architecture diagram:

┌──────────────── Browser ─────────────────┐
│ Memory Cache │ Disk Cache │ LocalStorage  │
│   0 ms       │  ~5 ms     │   ~1 ms      │
└──────────────────┬───────────────────────┘
                   │ cache miss
┌──────────────── CDN Edge ────────────────┐
│ Static assets: JS, CSS, Images, Fonts    │
│ Cache-Control: public, max-age=31536000  │
│ ~50 ms latency (nearest edge)            │
└──────────────────┬───────────────────────┘
                   │ cache miss / dynamic
┌──────────── Application Servers ─────────┐
│ Redis: hot data (1-5 min), sessions      │
│ ~1-2 ms latency                          │
└──────────────────┬───────────────────────┘
                   │ cache miss
┌──────────── Database Layer ──────────────┐
│ Query cache (~5-10 ms)                   │
│ Read replicas (~10-20 ms)                │
│ Primary DB (~20-50 ms)                   │
└──────────────────────────────────────────┘

Comparison

StrategyConsistencyComplexityBest for
TTLMay serve stale dataLowPublic content, news feeds
Write-throughAlways consistentMediumUser profiles, settings
Event-basedFlexible, near-real-timeHighRelated data updates
Cache tagsBulk invalidationMediumCategory pages, lists

Interview guidance

How to explain it:

"Caching stores frequently accessed data in fast storage to reduce latency and database load. A multi-layer strategy includes browser cache, CDN, application cache (Redis), and database cache. The hardest problem is invalidation — TTL, write-through, and event-based are the main strategies."

Common follow-ups:

QuestionKey points
How do you decide cache TTL?Based on data change frequency and staleness tolerance. User profiles: 5 min. Prices: 1 min. Static content: 1 year (versioned URLs). News feed: 30s. Popular items cached longer (adaptive TTL)
Cache consistency across multiple servers?Redis pub/sub for invalidation messages — when server A updates, all servers clear local caches. Or use Redis as single source of truth with no local cache. Trade-off: consistency vs latency
What is cache stampede and how to prevent it?Many requests hit same expired key, all bypass cache and hit DB. Prevention: request coalescing (only one loads, others wait), probabilistic early recomputation, locking

Difficulty: Intermediate


Q4: Database Design

Database design covers choosing between SQL and NoSQL, designing schemas, sharding data across servers, replicating for reads, and indexing for performance.

Syntax

ConceptDescription
SQL (relational)Fixed schema, ACID transactions, JOINs, foreign keys
NoSQL (document)Flexible schema, horizontal scaling, eventual consistency
ShardingSplit data across servers by shard key (e.g., user_id % N)
ReplicationMaster-slave (reads), multi-master (writes)
B-tree indexRange queries, ORDER BY
Hash indexEquality lookups
Full-text indexText search
Covering indexQuery satisfied entirely from index

Description

Choose SQL for structured data with complex relationships and ACID transactions (banking, e-commerce orders). Choose NoSQL for flexible schemas, horizontal scalability, and when eventual consistency is acceptable (social media, logs, analytics).

CriteriaSQL (PostgreSQL)NoSQL (MongoDB)
SchemaFixed, predefinedFlexible, dynamic
TransactionsACID, multi-rowLimited (single document)
RelationshipsJOINs, foreign keysEmbedded documents or references
ScalingVertical (harder to shard)Horizontal (built-in sharding)
Query languageSQL (powerful, standardized)JSON-like queries
ConsistencyStrongEventual
Best forBanking, e-commerceSocial media, logs, IoT

Note: Denormalize for read-heavy workloads where JOIN cost is high. For example, store the user name in a posts table to avoid a JOIN on every feed query. Trade-off: faster reads, slower writes, data duplication.

Examples

SQL schema — e-commerce with ACID transactions:

// Schema interfaces
interface User { id: number; email: string; name: string; created_at: Date; }
interface Product { id: number; name: string; price: number; stock: number; }
interface Order {
  id: number; user_id: number; total_amount: number;
  status: 'pending' | 'processing' | 'shipped' | 'delivered';
}
interface OrderItem {
  id: number; order_id: number; product_id: number;
  quantity: number; price: number;
}

ACID transaction — creating an order:

class SQLDatabaseLayer {
  private db: any; // PostgreSQL connection

  async createOrder(
    userId: number, items: { productId: number; quantity: number }[]
  ): Promise<any> {
    await this.db.query('BEGIN');
    try {
      let total = 0;
      for (const item of items) {
        const product = await this.db.query(
          'SELECT price, stock FROM products WHERE id = $1 FOR UPDATE',
          [item.productId]
        );
        if (product.stock < item.quantity) throw new Error('Insufficient stock');
        total += product.price * item.quantity;
      }

      const order = await this.db.query(
        'INSERT INTO orders (user_id, total_amount, status) VALUES ($1, $2, $3) RETURNING *',
        [userId, total, 'pending']
      );

      for (const item of items) {
        await this.db.query(
          `INSERT INTO order_items (order_id, product_id, quantity, price)
           VALUES ($1, $2, $3, (SELECT price FROM products WHERE id = $2))`,
          [order.id, item.productId, item.quantity]
        );
        await this.db.query(
          'UPDATE products SET stock = stock - $1 WHERE id = $2',
          [item.quantity, item.productId]
        );
      }
      await this.db.query('COMMIT');
      return order;
    } catch (error) {
      await this.db.query('ROLLBACK');
      throw error;
    }
  }

Complex JOIN query with indexes:

  // continued from SQLDatabaseLayer

  async getUserOrders(userId: number): Promise<any[]> {
    // Requires: CREATE INDEX idx_orders_user_id ON orders(user_id);
    //           CREATE INDEX idx_order_items_order_id ON order_items(order_id);
    return this.db.query(`
      SELECT o.id, o.total_amount, o.status, o.created_at,
        json_agg(json_build_object(
          'product_name', p.name, 'quantity', oi.quantity, 'price', oi.price
        )) as items
      FROM orders o
      JOIN order_items oi ON o.id = oi.order_id
      JOIN products p ON oi.product_id = p.id
      WHERE o.user_id = $1
      GROUP BY o.id
      ORDER BY o.created_at DESC
      LIMIT 20
    `, [userId]);
  }
}

NoSQL document design — social media posts:

class NoSQLDatabaseLayer {
  private db: any; // MongoDB connection

  async createPost(userId: string, content: string, tags: string[]) {
    const post = {
      id: this.generateId(), userId, content,
      likes: 0, comments: [], tags, createdAt: Date.now(),
    };
    await this.db.collection('posts').insertOne(post);
    // Eventual consistency: update user's post count async
    this.db.collection('users').updateOne(
      { id: userId }, { $inc: { postCount: 1 } }
    );
    return post;
  }

  async addComment(postId: string, userId: string, content: string) {
    // Atomic push into embedded array
    await this.db.collection('posts').updateOne(
      { id: postId },
      {
        $push: { comments: { id: this.generateId(), userId, content, createdAt: Date.now() } },
        $inc: { commentCount: 1 },
      }
    );
  }

  async getPostsByTag(tag: string, limit = 20) {
    // Requires: db.posts.createIndex({ tags: 1, createdAt: -1 })
    return this.db.collection('posts')
      .find({ tags: tag }).sort({ createdAt: -1 }).limit(limit).toArray();
  }

  private generateId() { return Math.random().toString(36).substring(2, 15); }
}

Sharding strategy:

class ShardedDatabase {
  private shards = new Map<number, any>();

  constructor(private numShards: number) {
    for (let i = 0; i < numShards; i++) {
      this.shards.set(i, { id: i, host: `shard-${i}.db.example.com` });
    }
  }

  private getShardId(userId: string): number {
    let hash = 0;
    for (let i = 0; i < userId.length; i++) {
      hash = ((hash << 5) - hash) + userId.charCodeAt(i);
      hash = hash & hash;
    }
    return Math.abs(hash) % this.numShards;
  }

  async getUser(userId: string) {
    const shard = this.shards.get(this.getShardId(userId));
    return shard.query('SELECT * FROM users WHERE id = ?', [userId]);
  }

  async getAllUsers() {
    // Scatter-gather across all shards
    const results = await Promise.all(
      [...this.shards.values()].map(s => s.query('SELECT * FROM users'))
    );
    return results.flat();
  }
}

Master-slave replication:

class ReplicatedDatabase {
  private master: any;
  private slaves: any[];
  private currentSlaveIndex = 0;

  constructor(masterHost: string, slaveHosts: string[]) {
    this.master = { host: masterHost };
    this.slaves = slaveHosts.map(host => ({ host }));
  }

  async write(query: string, params: any[]) {
    return this.master.query(query, params); // Writes always to master
  }

  async read(query: string, params: any[]) {
    const slave = this.slaves[this.currentSlaveIndex];
    this.currentSlaveIndex = (this.currentSlaveIndex + 1) % this.slaves.length;
    return slave.query(query, params); // Round-robin reads from slaves
  }

  async readConsistent(query: string, params: any[]) {
    return this.master.query(query, params); // Strong consistency reads
  }
}

Indexing strategies:

-- B-Tree: range queries, ORDER BY
CREATE INDEX idx_orders_created_at ON orders(created_at);

-- Composite: multiple filter columns
CREATE INDEX idx_orders_user_status ON orders(user_id, status, created_at);

-- Hash: equality lookups
CREATE INDEX idx_users_email USING HASH ON users(email);

-- Full-text search
CREATE INDEX idx_products_search ON products
  USING GIN(to_tsvector('english', name || ' ' || description));

-- Covering index: query answered entirely from index
CREATE INDEX idx_orders_covering ON orders(user_id)
  INCLUDE (total_amount, status, created_at);

React pagination with optimistic updates:

function ProductList() {
  const [page, setPage] = useState(1);
  const { data, isLoading } = useQuery({
    queryKey: ['products', page],
    queryFn: async () => {
      const res = await fetch(`/api/products?page=${page}&size=20`);
      return res.json();
    },
    keepPreviousData: true,
  });

  if (isLoading) return <div>Loading...</div>;
  return (
    <div>
      {data?.products.map((p: any) => <ProductCard key={p.id} product={p} />)}
      <Pagination current={page} total={data?.total} onChange={setPage} />
    </div>
  );
}

React optimistic like button:

function LikeButton({ postId }: { postId: string }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: () => fetch(`/api/posts/${postId}/like`, { method: 'POST' }),
    onMutate: async () => {
      await queryClient.cancelQueries(['post', postId]);
      const prev = queryClient.getQueryData(['post', postId]);
      queryClient.setQueryData(['post', postId], (old: any) => ({
        ...old, likes: old.likes + 1, isLiked: true,
      }));
      return { prev };
    },
    onError: (_err, _vars, ctx) => {
      queryClient.setQueryData(['post', postId], ctx?.prev);
    },
    onSettled: () => queryClient.invalidateQueries(['post', postId]),
  });

  return <button onClick={() => mutation.mutate()}>Like</button>;
}

Architecture diagrams:

Master-Slave Replication:

              ┌──────────────┐
              │   Master DB  │  (Write)
              └──────┬───────┘
                     │ Async replication (10-100 ms lag)
        ┌────────────┼────────────┐
        ▼            ▼            ▼
   ┌─────────┐ ┌─────────┐ ┌─────────┐
   │ Slave 1 │ │ Slave 2 │ │ Slave 3 │  (Read)
   └─────────┘ └─────────┘ └─────────┘
   If master fails: promote a slave to master

Database Sharding:

   Application Layer
        │
   ┌────┼────┬────┬────┐
   ▼    ▼    ▼    ▼    ▼
Shard 0  Shard 1  Shard 2  Shard 3
Users    Users    Users    Users
0-25M    25-50M   50-75M   75-100M

Shard key: user_id % 4

Capacity estimation — Instagram-like app:

500M users

Writes per day:
- Posts: 500M * 0.5 = 250M
- Comments: 250M * 5 = 1.25B
- Likes: 250M * 20 = 5B
- Total: 6.5B/day = 75K WPS (peak 3x = 225K)

Storage (5 years):
- Posts: 250M/day * 365 * 5 * 1KB = 456 TB
- Images: 250M/day * 365 * 5 * 2MB = 912 PB (object storage)
- Comments: 1.25B/day * 365 * 5 * 200B = 456 TB
- Total DB: ~1 PB (excluding images)

Sharding: 1000 shards * 1TB each
Shard by user_id for data locality
Cross-shard queries via Elasticsearch

Comparison

AspectSQL (PostgreSQL)NoSQL (MongoDB)
SchemaFixed, predefinedFlexible, dynamic
TransactionsFull ACID, multi-rowSingle-document atomic ops
RelationshipsJOINs, foreign keysEmbedded documents or references
ScalingVertical first, sharding harderHorizontal, built-in sharding
ConsistencyStrongEventual (tunable)
Best forBanking, e-commerce, complex queriesSocial media, logs, IoT, rapid iteration

Interview guidance

How to explain it:

"Choose SQL for structured data with complex relationships and ACID transactions. Choose NoSQL for flexible schemas and horizontal scalability when eventual consistency is acceptable. Sharding splits data across servers by key. Replication creates copies for read scaling and failover."

Common follow-ups:

QuestionKey points
When to denormalize in SQL?Read-heavy workloads where JOIN cost is high. Store user name in posts table to avoid JOIN on every feed query. Trade-off: faster reads, slower writes, data duplication. Use materialized views or cache instead if consistency is critical
Zero-downtime database migration?(1) Add new column as nullable. (2) Deploy code writing to both old and new. (3) Backfill data. (4) Deploy code reading from new. (5) Remove old column. Use feature flags. Keep both schemas compatible during transition
Sharding challenges?Cross-shard queries are expensive (scatter-gather). Rebalancing when adding shards. Hot spots if shard key distribution is uneven. Solution: consistent hashing, virtual shards

Difficulty: Intermediate


Q5: API Design

API design covers REST, GraphQL, and gRPC paradigms, along with versioning strategies, rate limiting, pagination, and response formatting standards.

Syntax

ParadigmProtocolData formatKey feature
RESTHTTP/JSONJSONResource-based URLs, HTTP verbs
GraphQLHTTP/JSONJSONClient-specified queries, single endpoint
gRPCHTTP/2Protocol BuffersHigh-performance RPC, streaming
HTTP verbActionResponse code
GETRead resource(s)200 OK
POSTCreate resource201 Created
PUTFull update200 OK
PATCHPartial update200 OK
DELETEDelete resource204 No Content
Versioning strategyExamplePros / Cons
URL path/api/v1/usersClear, easy to route. Duplicates code
HeaderAPI-Version: 2Clean URLs. Hidden from clients
Content negotiationAccept: application/vnd.api.v2+jsonStandards-based. Complex

Description

RESTful APIs use HTTP verbs with resource-based URLs. Always include pagination, filtering, and sorting for list endpoints. Use proper status codes and consistent response envelopes.

GraphQL lets clients request exactly the data needed, solving over-fetching common in REST. A single endpoint serves all queries.

gRPC uses Protocol Buffers for high-performance inter-service RPC with streaming support.

Note: Use REST for public APIs with wide compatibility. Use GraphQL for mobile apps and frontend-driven development. Use gRPC for microservice-to-microservice communication where performance is critical.

Examples

RESTful API routes:

class RESTfulAPI {
  private app: any; // Express app

  setupRoutes(): void {
    // GET /api/users — list with pagination, filtering, sorting
    this.app.get('/api/users', async (req: any, res: any) => {
      const { page = 1, limit = 20, sort = 'created_at', order = 'desc', search, role } = req.query;
      if (limit > 100) return res.status(400).json({ error: 'Limit max 100' });

      const users = await this.userService.getUsers({
        page: +page, limit: +limit, sort, order, filters: { search, role },
      });

      res.json({
        data: users.items,
        meta: { page: users.page, limit: users.limit, total: users.total,
                totalPages: Math.ceil(users.total / users.limit) },
        links: {
          self: `/api/users?page=${page}&limit=${limit}`,
          next: +page < users.totalPages ? `/api/users?page=${+page + 1}&limit=${limit}` : null,
          prev: +page > 1 ? `/api/users?page=${+page - 1}&limit=${limit}` : null,
        },
      });
    });
  }

REST CRUD operations:

    // continued from setupRoutes

    // GET /api/users/:id
    this.app.get('/api/users/:id', async (req: any, res: any) => {
      const user = await this.userService.getUser(req.params.id);
      if (!user) return res.status(404).json({ error: 'User not found' });
      res.json({ data: user });
    });

    // POST /api/users — 201 with Location header
    this.app.post('/api/users', async (req: any, res: any) => {
      const errors = this.validateUser(req.body);
      if (errors.length) return res.status(400).json({ errors });
      const user = await this.userService.createUser(req.body);
      res.status(201).header('Location', `/api/users/${user.id}`).json({ data: user });
    });

    // PUT /api/users/:id — full update
    this.app.put('/api/users/:id', async (req: any, res: any) => {
      const user = await this.userService.updateUser(req.params.id, req.body);
      res.json({ data: user });
    });

    // DELETE /api/users/:id — 204 No Content
    this.app.delete('/api/users/:id', async (req: any, res: any) => {
      await this.userService.deleteUser(req.params.id);
      res.status(204).send();
    });
  }
}

GraphQL schema and query:

import { GraphQLObjectType, GraphQLString, GraphQLList, GraphQLInt } from 'graphql';

const UserType = new GraphQLObjectType({
  name: 'User',
  fields: () => ({
    id: { type: GraphQLString },
    name: { type: GraphQLString },
    email: { type: GraphQLString },
    posts: {
      type: new GraphQLList(PostType),
      resolve: (user) => postService.getUserPosts(user.id),
    },
  }),
});

const RootQuery = new GraphQLObjectType({
  name: 'Query',
  fields: {
    user: {
      type: UserType,
      args: { id: { type: GraphQLString } },
      resolve: (_parent, args) => userService.getUser(args.id),
    },
    users: {
      type: new GraphQLList(UserType),
      args: {
        limit: { type: GraphQLInt, defaultValue: 20 },
        offset: { type: GraphQLInt, defaultValue: 0 },
      },
      resolve: (_parent, args) => userService.getUsers(args.limit, args.offset),
    },
  },
});

GraphQL client query:

query GetUserWithPosts($userId: String!) {
  user(id: $userId) {
    id
    name
    email
    posts {
      id
      title
      content
      comments {
        id
        content
      }
    }
  }
}

gRPC Protocol Buffers definition:

syntax = "proto3";
package user;

service UserService {
  rpc GetUser (GetUserRequest) returns (User);
  rpc ListUsers (ListUsersRequest) returns (UserList);
  rpc CreateUser (CreateUserRequest) returns (User);
  rpc StreamUsers (StreamUsersRequest) returns (stream User);
}

message User {
  string id = 1;
  string name = 2;
  string email = 3;
  int64 created_at = 4;
}

message GetUserRequest { string id = 1; }
message ListUsersRequest { int32 page = 1; int32 limit = 2; }
message UserList { repeated User users = 1; int32 total = 2; }

gRPC server implementation:

class GRPCUserService {
  async GetUser(call: any, callback: any) {
    const user = await this.userService.getUser(call.request.id);
    callback(null, user);
  }

  async ListUsers(call: any, callback: any) {
    const users = await this.userService.getUsers(call.request.page, call.request.limit);
    callback(null, { users: users.items, total: users.total });
  }

  // Server streaming — sends multiple responses
  async StreamUsers(call: any) {
    const users = await this.userService.getAllUsers();
    for (const user of users) {
      call.write(user);
      await new Promise(r => setTimeout(r, 100));
    }
    call.end();
  }
}

API versioning strategies:

class VersionedAPI {
  setupRoutes(): void {
    // Strategy 1: URL versioning (most common)
    this.app.use('/api/v1', this.v1Routes());
    this.app.use('/api/v2', this.v2Routes());

    // Strategy 2: Header versioning
    this.app.get('/api/users', (req: any, res: any) => {
      const version = req.header('API-Version') || '1';
      if (version === '2') return this.getUsersV2(req, res);
      return this.getUsersV1(req, res);
    });
  }

  private v1Routes() {
    const router = express.Router();
    router.get('/users', (_req: any, res: any) => {
      res.json([{ id: 1, name: 'John' }]); // V1: simple array
    });
    return router;
  }

  private v2Routes() {
    const router = express.Router();
    router.get('/users', (_req: any, res: any) => {
      res.json({ // V2: envelope with metadata
        data: [{ id: 1, name: 'John', email: 'john@example.com' }],
        meta: { version: 2, total: 1 },
      });
    });
    return router;
  }
}

Token bucket rate limiter:

class RateLimiter {
  private requests = new Map<string, number[]>();

  async check(clientId: string, max: number, windowMs: number) {
    const now = Date.now();
    let reqs = this.requests.get(clientId) || [];
    reqs = reqs.filter(t => t > now - windowMs);

    if (reqs.length >= max) {
      return { allowed: false, remaining: 0, resetAt: reqs[0] + windowMs };
    }
    reqs.push(now);
    this.requests.set(clientId, reqs);
    return { allowed: true, remaining: max - reqs.length, resetAt: now + windowMs };
  }

  middleware(max = 100, windowMs = 60000) {
    return async (req: any, res: any, next: any) => {
      const result = await this.check(req.ip, max, windowMs);
      res.header('X-RateLimit-Limit', String(max));
      res.header('X-RateLimit-Remaining', String(result.remaining));
      if (!result.allowed) {
        return res.status(429).json({
          error: 'Rate limit exceeded',
          retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
        });
      }
      next();
    };
  }
}

Standard API response format:

interface APIResponse<T> {
  success: boolean;
  data?: T;
  error?: { code: string; message: string; details?: any };
  meta?: { timestamp: number; requestId: string; version: string };
}

class APIResponseFormatter {
  success<T>(data: T, extra?: any): APIResponse<T> {
    return {
      success: true, data,
      meta: { timestamp: Date.now(), requestId: crypto.randomUUID(), version: '2.0', ...extra },
    };
  }

  error(code: string, message: string, details?: any): APIResponse<never> {
    return {
      success: false,
      error: { code, message, details },
      meta: { timestamp: Date.now(), requestId: crypto.randomUUID(), version: '2.0' },
    };
  }
}

React with REST (React Query):

function UserList() {
  const [page, setPage] = useState(1);
  const { data, isLoading } = useQuery({
    queryKey: ['users', page],
    queryFn: async () => {
      const res = await fetch(`/api/v2/users?page=${page}&limit=20`);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json();
    },
    keepPreviousData: true,
    staleTime: 30000,
  });

  if (isLoading) return <div>Loading...</div>;
  return (
    <div>
      {data.data.map((u: any) => <UserCard key={u.id} user={u} />)}
      <Pagination current={page} total={data.meta.totalPages} onChange={setPage} />
    </div>
  );
}

React with GraphQL (Apollo):

import { useQuery, gql } from '@apollo/client';

const GET_USER = gql`
  query GetUser($userId: ID!) {
    user(id: $userId) { id, name, email, posts { id, title } }
  }
`;

function UserProfile({ userId }: { userId: string }) {
  const { data, loading } = useQuery(GET_USER, { variables: { userId } });
  if (loading) return <div>Loading...</div>;
  return (
    <div>
      <h1>{data.user.name}</h1>
      {data.user.posts.map((p: any) => <PostCard key={p.id} post={p} />)}
    </div>
  );
}

API client with rate-limit retry:

class APIClientWithRetry {
  constructor(private baseURL: string) {}

  async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
    for (let attempt = 0; attempt < 3; attempt++) {
      try {
        const res = await fetch(`${this.baseURL}${endpoint}`, options);
        if (res.status === 429) {
          const retryAfter = parseInt(res.headers.get('Retry-After') || '60');
          await new Promise(r => setTimeout(r, retryAfter * 1000));
          continue;
        }
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return await res.json();
      } catch (error) {
        if (attempt === 2) throw error;
        await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000));
      }
    }
    throw new Error('Request failed after 3 attempts');
  }
}

Comparison

FeatureRESTGraphQLgRPC
ProtocolHTTP/JSONHTTP/JSONHTTP/2 + Protobuf
Client controlFixed endpointsFlexible queriesFixed contracts
Over-fetchingCommonSolvedOptimized
CachingHTTP cache works nativelyComplex (custom)Custom needed
PerformanceGoodGoodExcellent
Learning curveEasyMediumHard
Web browser supportNativeNativeRequires gRPC-Web
MicroservicesGoodComplexExcellent
Use caseRecommended
Public API, wide compatibilityREST
Mobile apps, reduce over-fetchingGraphQL
Frontend-driven developmentGraphQL
Microservice-to-microservicegRPC
Streaming datagRPC
Type safety across servicesgRPC

Interview guidance

How to explain it:

"REST uses HTTP verbs with resource-based URLs and is best for public APIs. GraphQL lets clients request exactly the data they need, solving over-fetching. gRPC uses Protocol Buffers for high-performance inter-service communication. Always include pagination, rate limiting, and proper error handling."

Common follow-ups:

QuestionKey points
How to design APIs for mobile with poor connectivity?GraphQL to minimize payload. Batch requests. Offline-first with service workers. Delta sync — only changed data. Compress with gzip/brotli. Resume capability for uploads
How to handle breaking API changes?Version APIs (v1, v2). Support old versions 6-12 months with deprecation headers (Sunset, Deprecation). Add new fields as optional, never remove. Monitor usage before retiring. Stripe maintains 3 years of versions
REST vs GraphQL trade-offs?REST: simpler, HTTP caching, well-tooled. GraphQL: flexible queries, reduces round trips, but complex caching and potential N+1 query problems. Use DataLoader to batch GraphQL resolvers

Difficulty: Intermediate

API design checklist:

CategoryRequirements
EndpointsRESTful URLs (/resources/:id), proper HTTP verbs, nested resources
Request/ResponseConsistent JSON envelope, metadata (pagination, timestamps), proper status codes
PaginationCursor-based for large datasets, offset-based for small, include total count
Filtering and sortingQuery params (?sort=created_at&order=desc), full-text search
SecurityJWT/OAuth2 auth, RBAC, rate limiting (100 req/min), input validation
Performancegzip compression, ETag/Cache-Control headers, batch endpoints, field selection
DocumentationOpenAPI/Swagger spec, example requests/responses, error codes, changelog

See also