1. Courses
  2. System Design
  3. Scaling Building Blocks
  4. Stop 1

35 minBeginner

Scaling up vs scaling out

When one server is not enough, you can buy a bigger server or run more copies of the same one. This lesson shows when each choice works, and the one rule that makes "more copies" possible: keep your app servers stateless.

By the end you can

  • Explain vertical and horizontal scaling and the limit of each
  • Find the hidden state in a NestJS app that breaks when you run three copies
  • Run three copies of an API behind Nginx with Docker Compose
  • Make a nightly job run once, not once per copy
  • Work out how many copies you need from your peak numbers

The everyday picture

It is the last night before a big festival: Eid, Diwali or Christmas, whichever your city celebrates. One checkout counter is open and the queue reaches the frozen section. You have two choices.

The first choice is to make that one counter faster. Put your fastest cashier there, give her a better scanner and a second person to pack bags. This helps, up to a point. A person can only scan so many items a minute. And when she takes a break, the whole queue stops.

The second choice is to open four more counters. The floor manager sends each customer to a free counter. This only works if every counter can serve every customer. If a customer's discount voucher is on a slip kept in counter 1's drawer, counter 3 cannot honour it, and the customer must walk back to counter 1. The fix is to keep vouchers in the central system that every counter reads. Then the counters are interchangeable. In system design, that property is called "stateless".

Two ways to grow

means moving your app to a bigger machine. means running more copies of the same app and spreading requests across them. Each running copy is called an or replica.

Scaling up is the easy first move. You change no code, one server is simple to debug, and there is no network between the parts. It has three limits:

  • A ceiling. Cloud providers sell machines up to a maximum size. A very large machine is also expensive to keep running all day for a peak that lasts one hour.
  • One machine is one point of failure. If it dies, or you restart it to resize it, everything is down. A is the first thing reviewers look for.
  • More cores do not help one Node.js process much. Your NestJS code runs on one main JavaScript thread, so one Node process uses roughly one core for your request handling. A 16-core machine running one Node process mostly sits idle.

That last point surprises many developers. To use 16 cores, you run several Node processes, for example with PM2 cluster mode or several containers. At that moment you are already scaling out, on one machine, and you meet the same rule as the festival counters: any process must be able to serve any request.

Scaling out has no hard ceiling for the app tier, and one copy dying is not an outage. The price is that you need a load balancer in front (Load balancers), and your code must not keep anything important inside one copy.

A common, healthy pattern is to scale out the app servers and scale up the database. Postgres uses many cores well and turns extra RAM into cache. Spreading one database across machines is much harder (Replication and read replicas and Sharding and consistent hashing), so you delay it as long as you can.

Quick check

Which pattern does this lesson call common and healthy?

Scale up (vertical) 1 server 2 CPU · 4 GB upgrade restart 1 server 16 CPU · 64 GB no code change has a ceiling Scale out (horizontal) Load balancer requests API 1 API 2 API 3 add more copies
Scaling up swaps one server for a bigger one, with a restart and a size limit. Scaling out keeps the servers small and adds identical copies behind a load balancer, which only works when the copies are stateless.

Stateless servers: any counter serves any customer

A server keeps nothing between requests that another copy would need. Each request carries what identifies the user (a session id or a token), and shared data lives in shared stores. You can kill any copy at any moment and users notice nothing, except maybe one failed request that the app retries.

Most apps start stateful without anyone deciding it. A developer stores the login in memory because that is the default, or saves an upload to ./uploads because it works on their laptop. Here is where state usually hides in a NestJS app, and where it should go:

Hidden stateWhat breaks with 3 copiesMove it to
Login sessions in memory (the default MemoryStore of express-session)User looks logged out when the next request lands on another copyRedis session store, or signed tokens (Security essentials)
Uploaded files on local diskThe file exists on one copy only, and disappears on redeployObject storage such as S3 or MinIO (Files, object storage and CDNs)
In-memory cache (a Map)Each copy has a different view; clearing it on update clears only one copyRedis (Caching), or keep TTLs very short
@Cron() jobs inside the APIThe job runs three timesOne scheduled job in BullMQ, or one dedicated worker
Rate-limit counters in memoryThe real limit becomes three times higherRedis (Rate limiting)
WebSocket roomsA message reaches only users connected to the same copyRedis adapter (Real-time updates)
Settings edited on the server by handCopies slowly drift apartEnvironment variables set by the deploy

Quick check

You run 3 API copies. A photo upload is saved to ./uploads on copy 1. The next request for that photo lands on copy 2. What happens?

Login is the classic one. The picture below shows what happens when a user logs in on server 1 and the load balancer sends their next tap to server 2.

Before: in RAM App requests LB 1 login 2 GET Server 1 has user 42 Server 2 no session replies 401 user is "logged out" After: in Redis App requests LB 1 login 2 GET Server 1 stateless Server 2 200 OK SET GET Redis sess:abc = 42
Left: the login is stored in server 1's memory, so server 2 does not know the user and answers 401. Right: server 1 writes the session to Redis and server 2 reads it, so any copy can answer.
Think first: the load balancer can pin each user to one server ("sticky sessions"). Why not use that instead of Redis?

Sticky sessions hide the problem without removing it. When that server crashes or is redeployed, every user pinned to it is logged out at once. Load becomes uneven, because a few heavy users can all land on one copy. And when autoscaling adds a new copy, existing users stay stuck on the old ones. Use stickiness only as a short bridge while you move state out. The Load balancers lesson shows how it works.

Config lives in the environment

If copy 1 reads one database password and copy 2 reads another, you have two systems that look like one. The rule is: build one image, and give it its settings through environment variables when it starts. Staging and production run the same image with different environment values. Nobody edits files on a server over SSH.

In NestJS, ConfigModule.forRoot({ isGlobal: true }) from @nestjs/config reads a .env file on your laptop and real environment variables in production. Secrets such as the database password come from your platform's secret store, never from git (Security essentials).

Autoscaling: adding copies automatically

means the platform adds or removes copies for you, based on a metric. You choose three numbers: a minimum (at least 2 in production, so one can fail), a maximum (to cap cost and protect the database), and a target such as "keep average CPU near 60%". The last one is called . AWS Auto Scaling groups, ECS services and the Kubernetes Horizontal Pod Autoscaler all work this way.

Three things catch teams out:

  • It is slow. A new copy must start, connect to the database and pass its health check. That often takes a few minutes. A spike that lasts 30 seconds is over before help arrives. For peaks you can predict, such as festival week, salary day or the hour stores open, schedule extra copies ahead of time.
  • Pick a metric that tracks the work. For an API, CPU or requests per second per copy works. For background workers, queue length is better (Queues and background jobs).
  • Scaling in must be gentle. When a copy is removed, it gets a SIGTERM signal. It should stop taking new requests, finish the ones in flight, then exit. In NestJS, call app.enableShutdownHooks() so your modules can close connections cleanly.

Quick check

Stores open between 8:30 and 9:00, and Store app traffic triples within 10 minutes every day. A new copy takes about 4 minutes to be ready. What fits best?

The bottleneck moves to the database

When you triple your API copies, you also triple the number of queries that can hit the database at the same moment. Scaling out the app tier does not make a slow query faster. If the database CPU is already at 90%, more API copies make it worse, because more queries now fight for the same CPU.

Connections multiply too. Each copy has its own Prisma . In Prisma 5 and 6 the default pool size is the number of physical CPU cores times 2, plus 1. Ten copies on 4-core machines can open 90 connections, and Postgres allows 100 by default (max_connections). Set connection_limit on purpose and put PgBouncer in front when the count grows (Running Postgres in production).

So before adding copies, ask where the time goes. If most of a request's p95 is spent waiting on the database, fix the query (Reading query plans), add a cache (Caching) or move reports to a replica (Replication and read replicas). Add API copies when the API's own CPU is the limit.

For capacity planning, reuse the peak numbers from your estimate (Back-of-the-envelope math). Measure how many requests per second one copy can serve while still meeting your latency target, using a load test against a staging copy. Then:

copies = peak QPS ÷ (one copy's capacity × 0.7), rounded up, plus one spare.

The 0.7 keeps each copy at about 70% busy, because latency climbs fast as a server gets close to full. The spare means you still meet the peak when one copy crashes or is being redeployed.

Build it

Take a NestJS API, make it stateless, run three copies behind Nginx, and make its nightly job run once. You can practise the capacity math from step 8 in your browser, in the Practice section below.

  1. Audit the app with a stateless checklist

    Paste this into the repo as a pull request template section, and answer each line before you scale out.

    Markdown
    ## Stateless check (must be all "yes" before running 2+ copies)
    - [ ] Sessions live in Redis, or we use signed tokens. No MemoryStore.
    - [ ] No file is written to local disk except temp files deleted in the same request.
    - [ ] Uploads go to object storage (S3 / R2 / MinIO). We store only the key.
    - [ ] Any in-memory cache is OK to be different per copy, or it lives in Redis.
    - [ ] No @Cron / setInterval does business work inside the API process.
    - [ ] Rate-limit counters and locks live in Redis.
    - [ ] All config comes from environment variables. Same image in staging and prod.
    - [ ] The app handles SIGTERM: enableShutdownHooks(), finishes in-flight requests.
    - [ ] GET /health answers in under 50 ms and does not need a logged-in user.
  2. Add a health endpoint and a "served by" header

    The health endpoint lets Docker and the load balancer ask "are you alive?". The header shows which copy answered, so you can watch requests spread. The Load balancers lesson upgrades this check to ping the database.

    TypeScript
    // health.controller.ts
    import { Controller, Get } from '@nestjs/common';
    import { hostname } from 'node:os';
    
    @Controller('health')
    export class HealthController {
      @Get()
      check() {
        // Cheap on purpose: no DB call, no auth
        return { status: 'ok', instance: hostname(), uptimeS: Math.round(process.uptime()) };
      }
    }
    
    // main.ts
    import { NestFactory } from '@nestjs/core';
    import { hostname } from 'node:os';
    import type { Request, Response, NextFunction } from 'express';
    import { AppModule } from './app.module';
    
    async function bootstrap() {
      const app = await NestFactory.create(AppModule);
      app.enableShutdownHooks(); // close Prisma/Redis cleanly on SIGTERM
      app.use((req: Request, res: Response, next: NextFunction) => {
        res.setHeader('X-Served-By', hostname()); // in Docker: the container id
        next();
      });
      await app.listen(3000);
    }
    bootstrap();
  3. Write a Compose file that allows many copies

    Two things block scaling in Compose: a fixed container_name and a host port mapping on the API (three copies cannot all bind host port 3000). Only Nginx publishes a port.

    YAML
    # docker-compose.yml
    services:
      api:
        build: .
        # no container_name and no ports: so Compose can run several copies
        environment:
          DATABASE_URL: postgresql://app:app@db:5432/shop?connection_limit=5
          REDIS_URL: redis://redis:6379
        healthcheck:
          test: ["CMD", "node", "-e", "fetch('http://localhost:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
          interval: 10s
          timeout: 3s
          retries: 3
        depends_on: [db, redis]
      nginx:
        image: nginx:1.27-alpine
        ports: ["8080:80"]
        volumes:
          - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
        depends_on:
          api:
            condition: service_healthy
      redis:
        image: redis:7-alpine
      db:
        image: postgres:16
        environment:
          POSTGRES_USER: app
          POSTGRES_PASSWORD: app
          POSTGRES_DB: shop
  4. Point Nginx at the service name

    Inside Compose, the name api resolves to one IP address per running copy. When an upstream server name resolves to several addresses, Nginx uses all of them and rotates between them.

    nginx
    # nginx.conf
    upstream api_pool {
        server api:3000;        # resolved once at startup: one entry per copy
        keepalive 32;           # reuse connections to the copies
    }
    
    server {
        listen 80;
    
        location / {
            proxy_pass http://api_pool;
            proxy_http_version 1.1;
            proxy_set_header Connection "";          # needed for keepalive
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }
    }
  5. Scale to three and watch the requests spread

    Nginx resolves api only when it starts or reloads, so reload it after you change the number of copies.

    Terminal
    docker compose up -d --build --scale api=3
    docker compose exec nginx nginx -s reload     # pick up the new copies
    
    for i in 1 2 3 4 5 6; do
      curl -s -o /dev/null -D - http://localhost:8080/health | grep -i x-served-by
    done
    Output
    X-Served-By: 3f2a9c1d0b7e
    X-Served-By: 91c4e07aa2d3
    X-Served-By: c0d81b5f6e42
    X-Served-By: 3f2a9c1d0b7e
    X-Served-By: 91c4e07aa2d3
    X-Served-By: c0d81b5f6e42

    Now stop one copy with docker stop and run the loop again. The first request that Nginx sends to the dead copy may be slow or fail. Then Nginx marks that copy as failed for a while and the other two keep answering. The Load balancers lesson tunes how fast that happens.

  6. Move sessions to Redis

    A small session service with ioredis. The cookie holds only a random id; the data lives in Redis with an expiry, so every copy sees the same login.

    TypeScript
    // session.service.ts
    import { Inject, Injectable } from '@nestjs/common';
    import { randomBytes } from 'node:crypto';
    import type Redis from 'ioredis';
    
    export interface SessionData { userId: number; siteIds: number[] }
    const TTL_S = 8 * 60 * 60; // one store shift
    
    @Injectable()
    export class SessionService {
      // provider: { provide: 'REDIS', useFactory: () => new Redis(process.env.REDIS_URL!) }
      constructor(@Inject('REDIS') private readonly redis: Redis) {}
    
      async create(data: SessionData): Promise<string> {
        const sid = randomBytes(32).toString('base64url'); // unguessable id for the cookie
        await this.redis.set(`sess:${sid}`, JSON.stringify(data), 'EX', TTL_S);
        return sid;
      }
    
      async get(sid: string): Promise<SessionData | null> {
        const raw = await this.redis.get(`sess:${sid}`);
        return raw ? (JSON.parse(raw) as SessionData) : null;
      }
    
      async destroy(sid: string): Promise<void> {
        await this.redis.del(`sess:${sid}`); // logout works on every copy at once
      }
    }
  7. Make the nightly job run once

    Replace the in-process cron with a BullMQ . The schedule is stored in Redis under a fixed id, so when all three copies call upsertJobScheduler on startup there is still one schedule. Each run becomes one job, and only one worker takes it. upsertJobScheduler needs BullMQ 5.16 or later; older versions use queue.add with a repeat option. The Queues and background jobs lesson covers queues fully.

    TypeScript
    import { Injectable } from '@nestjs/common';
    import { Cron } from '@nestjs/schedule';
    
    @Injectable()
    export class StockReportCron {
      // Every copy of the API has this timer, so 3 copies = 3 runs
      @Cron('0 2 * * *', { timeZone: 'Asia/Jakarta' })   // the stores' time zone
      async nightly() {
        await this.buildLowStockReportAndSendSms();
      }
    
      private async buildLowStockReportAndSendSms() { /* ... */ }
    }
  8. Do the capacity math

    Load-test one copy in staging (for example npx autocannon -c 50 -d 30 http://staging:8080/articles/1001) and find the highest request rate where p95 still meets your target. Then write the plan down where the team can see it.

    Output
    Peak QPS (from your estimate)               400 req/s
    One copy at p95 < 300 ms (load test)        150 req/s
    Keep each copy at ~70%                      150 x 0.7 = 105 req/s
    Copies for the peak                         400 / 105 = 3.8  -> 4
    Plus one spare (crash or deploy)            5 copies
    DB connections                              5 x connection_limit 5 = 25
    Postgres max_connections                    100  -> fine, room for workers

Bring it to your team

Use these when someone proposes "we need more servers" or "we need a bigger server".

  • If I kill one API copy right now, what does a store manager in the middle of receiving a delivery notice?
  • Where do sessions, uploaded files and cached data live? Is any of it in one server's memory or disk?
  • Which scheduled jobs do we have, and what makes each one run once when we run several copies?
  • What are our minimum and maximum copy counts, which metric scales us, and how long does a new copy take to be ready?
  • If we double the API copies, how many database connections do we open in total, and what is max_connections?
  • What peak QPS did we plan for, and did we measure one copy's capacity with a load test?

Red flags

  • "Let's get a bigger server" with no measurement of where request time goes.
  • Uploads written to ./uploads on the API server.
  • Sticky sessions switched on to "fix" random logouts.
  • Only one copy in production, with a plan to "add more when it gets slow".
  • @Cron() or setInterval doing business work inside the API, with no single owner.

Practice

Turn the capacity math into a function

Write copiesNeeded(peakQps, perCopyQps, headroom, spares). It returns how many copies to run: peak QPS divided by (one copy's capacity × headroom), rounded up, plus the spares. Headroom defaults to 0.7 and spares to 1. Production never runs fewer than 2 copies, so return at least 2.

JavaScript exerciseRuns in your browser with tests
function copiesNeeded(peakQps, perCopyQps, headroom = 0.7, spares = 1) {
  // 1. how many requests per second one copy should carry (capacity x headroom)
  // 2. copies for the peak, rounded up
  // 3. add the spares, and never return fewer than 2
}
Ctrl + Enter runs
Show a solution
JavaScript
function copiesNeeded(peakQps, perCopyQps, headroom = 0.7, spares = 1) {
  const safePerCopy = perCopyQps * headroom;          // keep each copy about 70% busy
  const forPeak = Math.ceil(peakQps / safePerCopy);   // 3.8 copies means 4
  return Math.max(2, forPeak + spares);               // spare for a crash or a deploy
}
Pass the exercise to finish this task.

Find the hidden state

Your team wants to run the Partner app's API as 3 copies next week. A developer describes how it works today:

  • Logins use express-session with its default store.
  • Shop owners upload a photo of their shop front, saved to ./uploads/shops.
  • A Map caches the product catalogue for 60 seconds.
  • A @Cron() job at 23:00 emails each partner shop its daily invoice.
  • Login allows 5 tries per minute, counted in memory.
  • GET /health runs the full stock report "to prove the database works".

For each line, write one sentence on what breaks with 3 copies and one on where the state should go.

  • Sessions: users look logged out when a request lands on another copy; you moved them to Redis or signed tokens
  • Photos: you moved them to object storage and kept only the key in the database
  • Catalogue cache: you decided whether 60 seconds of different answers per copy is acceptable, or moved it to Redis
  • Invoices: you gave the 23:00 job one owner, so each shop gets one email instead of three
  • Login limit: you moved the counter to Redis, so the real limit stays 5 and does not become 15
  • Health: you made the check cheap, with no report and no login
Show a model answer

Sessions. The default MemoryStore lives in one copy's RAM, so the next request on another copy gets a 401. Use a Redis session store or signed tokens.

Photos. A photo saved on copy 1 is missing on copies 2 and 3 and is deleted by the next deploy. Upload to object storage and store the key.

Catalogue. Each copy refreshes at a different moment, so two taps can show two prices for up to 60 seconds. That may be fine for browsing if checkout re-reads the price; if not, cache in Redis.

Invoices. Every copy runs the timer, so every shop gets three emails. Use a BullMQ job scheduler with a fixed id, or one dedicated worker.

Login limit. Each copy counts on its own, so an attacker gets about 15 tries a minute. Keep the counter in Redis.

Health. A heavy report on every check, every 2 seconds, from each load balancer, loads the database for nothing. Return a cheap response in under 50 ms.

Check yourself

Your NestJS API runs as one Node process on a 4-core VM. During the rush, one core is at 100% and three are nearly idle. What helps most?

You go from 1 to 3 API copies. Users now say they are "randomly logged out" a few taps after logging in. What is the most likely cause?

After scaling to 3 copies, customers got the same nightly SMS three times. What is the best fix?

Flashcards

Open my review

Key takeaways

  • Scaling up is simple but has a ceiling, needs restarts, and leaves one point of failure. One Node process uses about one core.
  • Scaling out works only when servers are stateless: sessions, files, caches, schedules and counters live in shared stores.
  • Ship one image, set config through environment variables, run at least two copies, and expose a cheap /health.
  • Scheduled jobs need one owner: a BullMQ job scheduler or one dedicated worker.
  • More app copies push more load and connections onto the database. Measure one copy's capacity and plan for peak plus one spare.

Sources

Episode transcriptOpen more counters, not a faster cashier · about 13 minutes · tap any line to play from there
  1. NadiaHave you ever received the same SMS three times in one minute?
  2. ImranFrom a bank, once. Very annoying. Why?
  3. NadiaBecause in this lesson, a team moves its API from one copy to three. The next morning, every customer gets the your points are expiring SMS three times. Nobody touched the SMS code.
  4. ImranSo something about running three copies broke it.
  5. NadiaExactly. Today is about what you do when one server isn't enough. You can buy a bigger server, or run more copies of the same one. And there's one rule that makes more copies possible: keep your app servers stateless.
  6. NadiaPicture the last night before a big festival, Eid or Diwali or Christmas, whichever your city celebrates. One checkout counter is open, and the queue reaches the frozen section. You have two choices. The first is to make that one counter faster. Your fastest cashier, a better scanner, a second person packing bags.
  7. ImranThat helps, but a person can only scan so many items a minute. And when she takes a break, the whole queue stops.
  8. NadiaRight. The second choice is to open four more counters, and the floor manager sends each customer to a free one. But that only works if every counter can serve every customer.
  9. ImranWhat would stop that?
  10. NadiaSay a customer's discount voucher is on a slip kept in counter one's drawer. Counter three can't honour it, so the customer walks back to counter one. The fix is to keep vouchers in the central system that every counter reads. Then the counters are interchangeable. That property is called stateless.
  11. ImranSo the faster cashier is scaling up, and more counters is scaling out.
  12. NadiaYes. Vertical scaling means moving to a bigger machine. Horizontal scaling means running more copies of the same app and spreading requests across them. Each copy is called an instance.
  13. NadiaScaling up is the easy first move. No code changes, one server is simple to debug. But it has limits. There's a ceiling: cloud providers only sell machines up to a certain size. A huge machine is expensive to keep all day for a peak that lasts an hour. And one machine is one point of failure.
  14. ImranIf it dies, or you restart it to resize it, everything is down.
  15. NadiaExactly. And the third limit surprises many developers. More cores don't help one Node.js process much.
  16. ImranWait. So if I move our NestJS API to a sixteen-core machine, it gets roughly sixteen times faster?
  17. NadiaThat's the trap, Imran. Your NestJS code runs on one main JavaScript thread, so one Node process uses roughly one core for handling requests. A sixteen-core machine running one Node process mostly sits idle.
  18. ImranSo to use those cores, you run several Node processes.
  19. NadiaYes, with PM2 cluster mode or several containers. And at that moment you're already scaling out, on one machine. You meet the same rule as the festival counters: any process must be able to serve any request.
  20. NadiaIf you can see the first diagram, it puts both choices side by side. If not, picture on the left one small server, two CPUs, being replaced by one big server with sixteen. On the right, a load balancer in front of three identical small API copies, with room to add more.
  21. ImranAnd the database? Do we scale that out too?
  22. NadiaA common, healthy pattern is: scale out the app servers, scale up the database. Postgres uses many cores well, and turns extra RAM into cache. Spreading one database across machines is much harder, as we saw with replicas and sharding, so you delay it as long as you can.
  23. ImranOkay. So what does stateless actually mean for my API?
  24. NadiaA stateless server keeps nothing between requests that another copy would need. Each request carries what identifies the user, like a session ID or a token, and shared data lives in shared stores. You can kill any copy at any moment, and users notice nothing, except maybe one failed request that gets retried.
  25. ImranAnd most apps are stateful without anyone deciding it.
  26. NadiaExactly. A developer stores logins in memory because that's the default. Or saves uploads to a local folder, because it works on their laptop. The lesson has a table of where state hides.
  27. ImranGive me the main ones.
  28. NadiaUploaded files on local disk exist on one copy only, and disappear on redeploy, so they go to object storage. An in-memory cache gives each copy a different view, so it goes to Redis. Rate-limit counters in memory make the real limit three times higher. And scheduled jobs inside the API run once per copy.
  29. ImranThere's my triple SMS.
  30. NadiaThat's it. The cron decorator in NestJS runs inside every copy of the app. Three copies, three runs, three SMS messages. Scheduled work needs one owner.
  31. NadiaBut the classic one is login. The second diagram shows it twice. On the left, a user logs in, and server one keeps the session in its memory. The next tap goes through the load balancer to server two. Server two has never heard of this user, so it replies four oh one. The user looks logged out.
  32. NadiaOn the right, server one writes the session to Redis. When the next tap lands on server two, it reads the session from Redis, and replies two hundred, okay. Any copy can answer.
  33. ImranOur store managers complain about random logouts sometimes. Could the load balancer just pin each user to one server instead? That seems easier.
  34. NadiaThose are called sticky sessions, and they hide the problem without removing it. When that server crashes or is redeployed, everyone pinned to it is logged out at once. Load becomes uneven. And when autoscaling adds a copy, existing users stay stuck on the old ones.
  35. ImranSo stickiness is a short bridge at most, while we move state out.
  36. NadiaExactly, a bridge, not a fix.
  37. ImranWhat about configuration? Different copies with different settings?
  38. NadiaIf copy one reads one database password and copy two reads another, you have two systems that look like one. The twelve-factor rule says: build one image, and give it its settings through environment variables when it starts. Staging and production run the same image. Nobody edits files on a server by hand.
  39. ImranAnd autoscaling? Can't the platform just add copies when we're busy?
  40. NadiaIt can. You pick a minimum, at least two in production so one can fail. A maximum, to cap cost and protect the database. And a target, like keep average CPU near sixty percent. AWS, ECS and Kubernetes all work this way.
  41. ImranThen at festival time we just let it scale.
  42. NadiaCareful. Autoscaling is slow. A new copy must start, connect to the database and pass its health check, which often takes a few minutes. A spike that lasts thirty seconds is over before help arrives. For peaks you can predict, like festival week, salary day or the hour stores open, schedule extra copies ahead of time.
  43. ImranThat fits our Store app. Most of our three hundred and twenty stores open within the same hour, and every manager opens the morning checklist, low-stock alerts and pending deliveries at once.
  44. NadiaThat's what Ken's team does in the lesson. Two copies all day, schedule four from eight thirty to eleven, and a higher minimum for festival week and the month-end stock count. Cheaper and faster than waiting for CPU-based scaling to notice the morning rush.
  45. NadiaOne more thing about scaling in. When a copy is removed, it gets a signal to stop. It should stop taking new requests, finish the ones in flight, and then exit. In NestJS, you turn on shutdown hooks so modules can close connections cleanly.
  46. ImranNadia, here's my worry. If we triple the API, doesn't the database get three times the load?
  47. NadiaExactly the right worry. Scaling the app doesn't make a slow query faster. If the database CPU is already at ninety percent, more API copies make it worse. And connections multiply. Ten copies on four-core machines, with Prisma's default pool, can open ninety connections, and Postgres allows a hundred by default.
  48. ImranSo before adding copies, ask where the time goes.
  49. NadiaYes. If most of a request's time is spent waiting on the database, fix the query, add a cache, or move reports to a replica. Add API copies when the API's own CPU is the limit.
  50. ImranSo what does the Build it section have me do?
  51. NadiaFirst, a stateless checklist for your pull request template. Sessions in Redis, no files on local disk, no cron jobs inside the API, config from the environment, a cheap health endpoint. Every line must be yes before you run two copies.
  52. NadiaThen you add that health endpoint, plus a header showing which copy answered. You write a Docker Compose file with no fixed container name and no host port on the API, because three copies can't all claim the same port. Only Nginx publishes a port.
  53. ImranAnd Nginx finds the copies how?
  54. NadiaInside Compose, the name API resolves to one address per running copy, and Nginx rotates between them. You scale to three, reload Nginx, and call the health endpoint six times. You see three different container IDs, in turn. Stop one copy, and the other two keep answering.
  55. NadiaThen you move sessions to Redis. The cookie holds only a random ID, and the data lives in Redis with an eight-hour expiry, one store shift.
  56. ImranAnd the triple SMS?
  57. NadiaYou replace the cron with a BullMQ job scheduler, stored in Redis under a fixed ID. All three copies register the same schedule, so there's still only one, and each run is taken by one worker.
  58. ImranAnd the capacity math at the end?
  59. NadiaYou load-test one copy in staging. In the lesson's example, peak is four hundred requests a second, and one copy serves a hundred fifty while staying fast. You keep each copy at about seventy percent, so a hundred and five each.
  60. ImranFour hundred divided by a hundred and five is about three point eight. So four copies.
  61. NadiaPlus one spare, for a crash or a deploy. Five copies. With a connection limit of five each, that's twenty-five database connections. Well within the hundred.
  62. ImranI'd like to try that math myself.
  63. NadiaThe Practice section turns it into a small function you write and test in the browser. There's also a second task: a list of six things a real app does, and you find the hidden state in each one.
  64. ImranMy review questions. If I kill one API copy right now, what does a store manager in the middle of receiving a delivery notice? Where do sessions, uploads and cached data live? And which scheduled jobs do we have, and what makes each one run once?
  65. NadiaAdd one: if we double the copies, how many database connections do we open in total?
  66. NadiaNow a puzzle. Your API runs as one Node process on a four-core machine. During the rush, one core is at a hundred percent and three are nearly idle. What helps most? Take a second.
  67. ImranNot a bigger machine. One process only uses about one core. So I run four processes, behind a balancer.
  68. NadiaExactly. And that only works if the app is stateless. More cores or more RAM wouldn't help that single process at all.
  69. ImranTakeaways. One. Scaling up is simple, but has a ceiling, needs restarts, and leaves one point of failure. Two. Scaling out needs stateless servers, so sessions, files, caches, schedules and counters live in shared stores. Three. Scheduled jobs need one owner, or my customers get three SMS messages.
  70. NadiaAll good. One nuance: more app copies push more load and connections onto the database. Measure one copy's capacity, and plan for the peak plus one spare.
  71. ImranI'll go through the flashcards on the bus tomorrow. And the floor manager sending customers to counters?
  72. NadiaThat's the load balancer, and it's the whole next episode. How it chooses a counter, and how it notices when one has closed.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Capacity plan

Expected peak traffic, a sized design and Find my limit, with headroom and cost per month as criteria.

Open in the System Design Studio
Next stopLoad balancers
Was this lesson helpful?
Questions and discussion

Loading the discussion…