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
Vertical scaling means moving your app to a bigger machine. Horizontal scaling means running more copies of the same app and spreading requests across them. Each running copy is called an instance 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 single point of failure 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?
App servers are easy to copy once they are stateless, so you add more of them. Postgres uses extra cores and RAM well, and splitting one database across machines is hard, so you give it a bigger machine first. Scaling everything up only delays the ceiling and keeps the single point of failure.
Stateless servers: any counter serves any customer
A stateless 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 state | What breaks with 3 copies | Move it to |
|---|---|---|
Login sessions in memory (the default MemoryStore of express-session) | User looks logged out when the next request lands on another copy | Redis session store, or signed tokens (Security essentials) |
| Uploaded files on local disk | The file exists on one copy only, and disappears on redeploy | Object 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 copy | Redis (Caching), or keep TTLs very short |
@Cron() jobs inside the API | The job runs three times | One scheduled job in BullMQ, or one dedicated worker |
| Rate-limit counters in memory | The real limit becomes three times higher | Redis (Rate limiting) |
| WebSocket rooms | A message reaches only users connected to the same copy | Redis adapter (Real-time updates) |
| Settings edited on the server by hand | Copies slowly drift apart | Environment 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?
Each copy has its own disk, and nothing copies files between them. The load balancer does not know which copy holds which file either. That is why uploads go to object storage, which every copy can read.
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.
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 twelve-factor app 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
Autoscaling 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 target tracking. 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
SIGTERMsignal. It should stop taking new requests, finish the ones in flight, then exit. In NestJS, callapp.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 rush is predictable, so add copies before it starts. Target tracking only reacts after CPU rises, and each new copy needs minutes, so users would feel the first part of the rush. A higher maximum only caps how far scaling may go; it does not make scaling start earlier.
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 connection pool. 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.
-
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. -
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(); -
Write a Compose file that allows many copies
Two things block scaling in Compose: a fixed
container_nameand 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 -
Point Nginx at the service name
Inside Compose, the name
apiresolves to one IP address per running copy. When anupstreamserver 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; } } -
Scale to three and watch the requests spread
Nginx resolves
apionly when it starts or reloads, so reload it after you change the number of copies.Terminaldocker 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 doneOutputX-Served-By: 3f2a9c1d0b7e X-Served-By: 91c4e07aa2d3 X-Served-By: c0d81b5f6e42 X-Served-By: 3f2a9c1d0b7e X-Served-By: 91c4e07aa2d3 X-Served-By: c0d81b5f6e42Now stop one copy with
docker stopand 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. -
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 } } -
Make the nightly job run once
Replace the in-process cron with a BullMQ job scheduler. The schedule is stored in Redis under a fixed id, so when all three copies call
upsertJobScheduleron startup there is still one schedule. Each run becomes one job, and only one worker takes it.upsertJobSchedulerneeds BullMQ 5.16 or later; older versions usequeue.addwith arepeatoption. The Queues and background jobs lesson covers queues fully.TypeScriptimport { 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() { /* ... */ } }TypeScriptimport { Injectable, OnModuleInit } from '@nestjs/common'; import { InjectQueue, Processor, WorkerHost } from '@nestjs/bullmq'; import { Job, Queue } from 'bullmq'; @Injectable() export class StockReportScheduler implements OnModuleInit { constructor(@InjectQueue('reports') private readonly reports: Queue) {} async onModuleInit() { // Same scheduler id from every copy = still ONE schedule in Redis await this.reports.upsertJobScheduler( 'nightly-low-stock', { pattern: '0 2 * * *', tz: 'Asia/Jakarta' }, // the stores' time zone { name: 'low-stock-report', data: {} }, ); } } @Processor('reports') export class ReportsProcessor extends WorkerHost { async process(job: Job): Promise<void> { // Each 02:00 run is one job, taken by exactly one worker if (job.name === 'low-stock-report') { /* build report, send SMS */ } } } // Module imports: BullModule.forRoot({ connection: { host: 'redis', port: 6379 } }), // BullModule.registerQueue({ name: 'reports' }) -
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.OutputPeak 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
./uploadson 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()orsetIntervaldoing 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.
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
}
test('the lesson example: 400 req/s at 150 per copy needs 5', () => {
expect(copiesNeeded(400, 150)).toBe(5);
});
test('rounds up before adding the spare', () => {
expect(copiesNeeded(220, 100)).toBe(5); // 220 / 70 = 3.1, so 4, plus 1
});
test('uses the headroom and spares you pass in', () => {
expect(copiesNeeded(300, 100, 0.5)).toBe(7); // 300 / 50 = 6, plus 1
expect(copiesNeeded(400, 150, 0.7, 2)).toBe(6); // 4, plus 2
});
test('never fewer than 2 copies, even at night', () => {
expect(copiesNeeded(0, 150)).toBe(2);
expect(copiesNeeded(10, 150)).toBe(2);
});
Show a solution
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
}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
Mapcaches 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 /healthruns 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?
One Node process runs your JavaScript on one main thread, so it uses about one core. More cores or RAM do not help that single process. Several processes behind a balancer use all four cores, and that requires the app to be stateless. Postgres settings do not matter here because the database is not the bottleneck.
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?
The login was saved in one copy's memory, and the next request went to another copy that has never seen it. The balancing algorithm is not the cause: any algorithm will send some requests elsewhere. Move sessions to Redis or use signed tokens.
After scaling to 3 copies, customers got the same nightly SMS three times. What is the best fix?
A BullMQ job scheduler with a fixed id gives the job one owner no matter how many copies start. A random delay still runs it three times. Hostname tricks break when containers get new names, and scaling down at night is fragile and still fails during deploys.
Flashcards
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
- The Twelve-Factor App, Adam Wiggins, 2011
- Horizontal Pod Autoscaling, Kubernetes documentation
- Job Schedulers, BullMQ documentation