Episode 6 — Scaling Reliability Microservices Web3 / 6.5 — Scaling Concepts
6.5.c -- Stateless Design
In one sentence: A stateless application stores no session data on the server -- every request contains everything needed to process it -- which means any server instance can handle any request, making horizontal scaling, rolling deployments, and failure recovery trivial.
Navigation: <- 6.5.b -- Load Balancers | 6.5 Overview ->
1. What Does "Stateless" Mean?
A stateless server does not store any client-specific data (sessions, caches, uploaded files, user preferences) in its own memory or local filesystem between requests. Every request is self-contained.
STATEFUL SERVER STATELESS SERVER
Client: POST /login Client: POST /login
Server: { sessions[abc] = { user: 1 }} Server: returns JWT token
(stored in memory) (client stores it)
Client: GET /profile Client: GET /profile
Cookie: session=abc Authorization: Bearer <jwt>
Server: lookup sessions[abc] Server: verify JWT, extract user ID
→ user 1 found → user 1 verified
→ return profile → return profile
PROBLEM: If this server dies, NO PROBLEM: Any server can
session data is gone. If client verify the JWT. No server-side
hits a different server, session session storage needed.
is missing.
The key distinction
| Stateful | Stateless | |
|---|---|---|
| State lives | On the server (memory, local disk) | Externally (client, Redis, database, S3) |
| Any server can handle any request? | No (must hit the "right" server) | Yes |
| Scale horizontally? | Only with sticky sessions (fragile) | Freely (add/remove servers at will) |
| Server crash impact | Active sessions lost | Nothing lost (state is external) |
| Rolling deploy | Risky (drain sessions first) | Safe (just replace servers) |
2. Why Statelessness Enables Horizontal Scaling
When your application is stateless, the load balancer can send any request to any server. This is the fundamental requirement for horizontal scaling.
STATEFUL — requires sticky sessions:
Load Balancer (must track who goes where)
│
├── Alice → ALWAYS Server A (her session is on A)
├── Bob → ALWAYS Server B (his session is on B)
└── Carol → ALWAYS Server C (her session is on C)
If Server A dies → Alice loses her session
If Server C is overloaded → Carol is stuck on it
New Server D → gets no sessions (useless until new users arrive)
STATELESS — true load balancing:
Load Balancer (sends anywhere)
│
├── Request 1 (Alice) → Server B
├── Request 2 (Alice) → Server D
├── Request 3 (Bob) → Server A
├── Request 4 (Alice) → Server C
└── Request 5 (Carol) → Server D
If Server A dies → remaining servers absorb traffic instantly
If Server C is overloaded → LB routes away from it
New Server D → immediately receives traffic
What statelessness unlocks
- Auto-scaling -- add servers during peak, remove during off-peak. No session draining needed.
- Zero-downtime deployments -- deploy new code to fresh servers, shift traffic, kill old servers.
- Multi-region deployment -- replicate the same stateless app in multiple AWS regions behind a global load balancer.
- Container orchestration -- Kubernetes can kill, restart, and reschedule pods freely because pods hold no state.
- Cost efficiency -- use spot instances (cheap, can be terminated anytime) because losing an instance costs nothing.
3. Moving State Externally
The goal of stateless design is not to eliminate state -- your application absolutely needs state (user sessions, shopping carts, file uploads). The goal is to move state out of the server process and into dedicated external stores.
3.1 Sessions: In-memory -> Redis
// ============================================
// BEFORE: Stateful — sessions stored in memory
// ============================================
const express = require('express');
const session = require('express-session');
const app = express();
app.use(session({
secret: 'my-secret',
resave: false,
saveUninitialized: false,
// Default store: MemoryStore (lives in this process only!)
}));
app.post('/login', (req, res) => {
req.session.userId = 42; // Saved in THIS server's memory
req.session.role = 'admin'; // Lost if this server dies
res.json({ message: 'Logged in' });
});
app.get('/profile', (req, res) => {
// Only works if the request hits THE SAME server
if (!req.session.userId) return res.status(401).json({ error: 'Not logged in' });
res.json({ userId: req.session.userId, role: req.session.role });
});
// ============================================
// AFTER: Stateless — sessions stored in Redis
// ============================================
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const app = express();
// Connect to external Redis (shared by ALL server instances)
const redisClient = createClient({
url: process.env.REDIS_URL || 'redis://redis.internal:6379',
});
redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // No JavaScript access
maxAge: 24 * 60 * 60 * 1000, // 24 hours
sameSite: 'strict',
},
}));
app.post('/login', (req, res) => {
req.session.userId = 42; // Saved in Redis — accessible from ANY server
req.session.role = 'admin';
res.json({ message: 'Logged in' });
});
app.get('/profile', (req, res) => {
// Works no matter which server handles this request!
if (!req.session.userId) return res.status(401).json({ error: 'Not logged in' });
res.json({ userId: req.session.userId, role: req.session.role });
});
Before (stateful):
Server A memory: { sess_abc: { userId: 42 } }
Server B memory: { } ← knows nothing about session abc
After (Redis):
Server A → Redis → { sess_abc: { userId: 42 } }
Server B → Redis → { sess_abc: { userId: 42 } } ← same data!
Server C → Redis → { sess_abc: { userId: 42 } } ← same data!
3.2 Authentication: Sessions -> JWT
An even more stateless approach is to eliminate server-side sessions entirely using JSON Web Tokens (JWT).
const express = require('express');
const jwt = require('jsonwebtoken');
const app = express();
app.use(express.json());
const JWT_SECRET = process.env.JWT_SECRET;
// Login — create a self-contained token
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await authenticateUser(email, password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
// The token CONTAINS the user data — no server-side storage
const token = jwt.sign(
{
userId: user.id,
email: user.email,
role: user.role,
},
JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({ token });
});
// Middleware — verify token on every request
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded; // { userId, email, role } — from the token itself
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
// Protected route — zero server-side session state
app.get('/profile', authenticate, async (req, res) => {
// req.user was extracted from the JWT — no Redis, no database lookup
const profile = await db.users.findById(req.user.userId);
res.json(profile);
});
JWT vs Redis sessions: when to use each
| Factor | JWT | Redis Sessions |
|---|---|---|
| Server-side storage | None (fully stateless) | Redis (shared external store) |
| Revocation | Hard (token is valid until expiry) | Easy (delete from Redis) |
| Token size | Larger (payload in every request) | Small (just a session ID cookie) |
| Performance | No network hop for auth | Redis lookup per request |
| Best for | APIs, mobile apps, microservices | Web apps that need instant logout/revocation |
| Logout | Client deletes token (server cannot force) | Server deletes session from Redis |
Common pattern: Use JWT for authentication + Redis for revocation (a "blocklist" of invalidated tokens).
3.3 File uploads: Local disk -> S3
// ============================================
// BEFORE: Stateful — files on local disk
// ============================================
const multer = require('multer');
// Files saved to THIS server's /uploads directory
const upload = multer({ dest: '/uploads/' });
app.post('/upload', upload.single('avatar'), (req, res) => {
// If next request goes to a different server,
// the file is not there!
res.json({ path: req.file.path }); // /uploads/abc123
});
app.get('/avatar/:filename', (req, res) => {
// Only works on the server that received the upload
res.sendFile(`/uploads/${req.params.filename}`);
});
// ============================================
// AFTER: Stateless — files in S3
// ============================================
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const multer = require('multer');
const s3 = new S3Client({ region: process.env.AWS_REGION });
const upload = multer({ storage: multer.memoryStorage() });
app.post('/upload', upload.single('avatar'), async (req, res) => {
const key = `avatars/${Date.now()}-${req.file.originalname}`;
await s3.send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
Body: req.file.buffer,
ContentType: req.file.mimetype,
}));
// Store S3 key in database — any server can access it
await db.users.updateOne(
{ _id: req.user.id },
{ avatarKey: key }
);
res.json({
url: `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${key}`,
});
});
// Better: Generate pre-signed URLs for direct client-to-S3 upload
app.get('/upload-url', authenticate, async (req, res) => {
const key = `avatars/${req.user.userId}/${Date.now()}.jpg`;
const url = await getSignedUrl(s3, new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
ContentType: 'image/jpeg',
}), { expiresIn: 300 }); // 5 minutes
res.json({ uploadUrl: url, key });
// Client uploads directly to S3 — server never touches the file
});
3.4 Caching: In-memory -> Redis
// ============================================
// BEFORE: Stateful — cache in process memory
// ============================================
const cache = new Map();
app.get('/api/products/:id', async (req, res) => {
const cacheKey = `product:${req.params.id}`;
if (cache.has(cacheKey)) {
return res.json(cache.get(cacheKey)); // Only hits on THIS server
}
const product = await db.products.findById(req.params.id);
cache.set(cacheKey, product);
res.json(product);
});
// ============================================
// AFTER: Stateless — cache in Redis
// ============================================
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
app.get('/api/products/:id', async (req, res) => {
const cacheKey = `product:${req.params.id}`;
// Check shared Redis cache — works from ANY server
const cached = await redis.get(cacheKey);
if (cached) {
return res.json(JSON.parse(cached));
}
const product = await db.products.findById(req.params.id);
await redis.setex(cacheKey, 3600, JSON.stringify(product)); // TTL: 1 hour
res.json(product);
});
4. Sticky Sessions Deep Dive
What sticky sessions are
Sticky sessions (session affinity) configure the load balancer to route all requests from a given client to the same backend server. This is implemented via cookies or IP hashing.
Why they exist
Sticky sessions are a workaround for stateful application design. If your server stores sessions in memory, the only way to make load balancing work is to ensure the client always reaches the server with their session.
Why you should avoid them
| Problem | Explanation |
|---|---|
| Uneven load | Some servers accumulate more sessions; others sit idle |
| Session loss on failure | Server dies = all sessions on it are gone |
| Cannot scale in | Removing a server means terminating its users' sessions |
| No spot instances | Cannot use cheap spot instances that may be reclaimed |
| Rolling deploys are risky | Must drain sessions before killing old servers |
| New servers are cold | Freshly launched servers get no existing sessions |
When sticky sessions are acceptable
- Short-term migration -- you are actively refactoring toward stateless but need something that works today.
- WebSocket connections -- a WebSocket is a persistent TCP connection to a specific server (though you should still store shared state externally).
- Server-side rendering with local cache -- if re-rendering is very expensive and the cache is server-local (but shared cache is better).
5. WebSocket Considerations
WebSocket connections are inherently stateful -- they maintain a persistent TCP connection between the client and a specific server. This creates challenges for horizontal scaling.
Client A ──WebSocket──> Server 1
Client B ──WebSocket──> Server 2
Client C ──WebSocket──> Server 1
Problem: Client A sends a message for Client B.
Server 1 has the message, but Client B is on Server 2.
How does the message get to Client B?
Solution: Pub/Sub with Redis
// WebSocket with Redis pub/sub for cross-server messaging
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');
const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: { origin: '*' },
});
// Redis pub/sub adapter — messages are broadcast across ALL servers
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
io.adapter(createAdapter(pubClient, subClient));
});
io.on('connection', (socket) => {
console.log(`Client connected: ${socket.id} on PID ${process.pid}`);
socket.on('join-room', (roomId) => {
socket.join(roomId);
});
socket.on('message', (data) => {
// This emits to ALL clients in the room, even on OTHER servers
// because the Redis adapter broadcasts the event
io.to(data.roomId).emit('message', {
from: socket.id,
text: data.text,
timestamp: Date.now(),
});
});
});
server.listen(3000, () => {
console.log(`Server ${process.pid} listening on port 3000`);
});
How Redis pub/sub solves cross-server WebSocket:
Client A (Server 1) sends message to Room "chat-123"
│
▼
Server 1 publishes to Redis channel "chat-123"
│
▼
Redis broadcasts to all subscribers
│
├── Server 1 receives → sends to Client A and Client C (in room on Server 1)
└── Server 2 receives → sends to Client B (in room on Server 2)
All clients in the room receive the message, regardless of which server
they are connected to.
6. Stateless Design Checklist
Use this checklist when auditing an existing application or designing a new one:
| Check | Question | Stateless Answer |
|---|---|---|
| Sessions | Where is session data stored? | Redis or JWT (not in-memory) |
| Auth | How is the user identified? | JWT token or Redis-backed session cookie |
| File storage | Where are uploaded files saved? | S3 or equivalent (not local disk) |
| Caching | Where is cached data stored? | Redis or Memcached (not process memory) |
| Temp files | Are temp files written to disk? | Use memory buffers or stream to S3 |
| Config | Is config stored in the server? | Environment variables or config service |
| WebSocket state | Is WS connection state local? | Use Redis pub/sub adapter |
| Scheduled jobs | Are cron jobs running on one server? | Use a distributed job queue (Bull, SQS) |
| Rate limiting | Is the rate limit counter local? | Use Redis-based rate limiting |
| Server identity | Does the app depend on hostname or IP? | Use service discovery, not hardcoded addresses |
7. Practical Migration: Stateful to Stateless Express App
Here is a step-by-step migration path for a typical stateful Express application:
Step 1: Identify all state
// Audit your app for these patterns:
// 1. In-memory sessions
app.use(session({ /* no store option = MemoryStore */ }));
// 2. Global variables storing state
const userCache = new Map(); // ← state
const rateLimits = {}; // ← state
let requestCount = 0; // ← state
// 3. Local file operations
fs.writeFileSync('/tmp/report.pdf', buffer); // ← state
multer({ dest: './uploads/' }); // ← state
// 4. Scheduled tasks
setInterval(() => cleanupExpiredSessions(), 60000); // ← state
cron.schedule('0 * * * *', sendDigestEmails); // ← state
Step 2: Externalise sessions
// Install: npm install connect-redis redis
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }), // ← external store
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
}));
Step 3: Externalise caching
// Replace Map/Object caches with Redis
const redis = new Redis(process.env.REDIS_URL);
// Before
const cache = new Map();
function getCached(key) { return cache.get(key); }
function setCached(key, val) { cache.set(key, val); }
// After
async function getCached(key) {
const val = await redis.get(key);
return val ? JSON.parse(val) : null;
}
async function setCached(key, val, ttlSeconds = 3600) {
await redis.setex(key, ttlSeconds, JSON.stringify(val));
}
Step 4: Externalise file storage
// Move uploaded files to S3 (see Section 3.3 above for full example)
// Move temp files to memory buffers or stream directly to S3/database
Step 5: Externalise scheduled jobs
// Before: setInterval or node-cron running on one server
// After: Use Bull (Redis-based job queue)
const Queue = require('bull');
// Queue is backed by Redis — any server can add jobs
const emailQueue = new Queue('email-digest', process.env.REDIS_URL);
// Only ONE worker across all servers processes each job (no duplicates)
emailQueue.process(async (job) => {
await sendDigestEmail(job.data.userId);
});
// Schedule recurring jobs
emailQueue.add(
{ type: 'daily-digest' },
{
repeat: { cron: '0 9 * * *' }, // Every day at 9 AM
removeOnComplete: true,
}
);
Step 6: Externalise rate limiting
// Before: in-memory counter
const rateLimits = {};
function checkRateLimit(ip) {
rateLimits[ip] = (rateLimits[ip] || 0) + 1;
return rateLimits[ip] <= 100;
}
// After: Redis-based rate limiter
const rateLimit = require('express-rate-limit');
const RedisRateLimitStore = require('rate-limit-redis').default;
app.use(rateLimit({
store: new RedisRateLimitStore({
sendCommand: (...args) => redisClient.sendCommand(args),
}),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true,
}));
Step 7: Verify statelessness
# Test: Run 2+ instances and ensure everything works
# without sticky sessions
# Terminal 1
PORT=3001 node app.js
# Terminal 2
PORT=3002 node app.js
# Nginx config (round robin, no sticky sessions)
upstream app {
server localhost:3001;
server localhost:3002;
}
# Test: Login on one request, access profile on another
curl -c cookies.txt http://localhost/login -X POST -d '...'
curl -b cookies.txt http://localhost/profile # May hit either server
curl -b cookies.txt http://localhost/profile # May hit the other server
# Both must succeed — if they do, the app is stateless
8. Common Mistakes
| Mistake | Why It Breaks Scaling | Fix |
|---|---|---|
Storing sessions in MemoryStore | Sessions lost on crash, unavailable on other servers | Use Redis store |
| Saving uploads to local disk | Files only exist on one server | Use S3 |
Using global variables for cache | Cache is per-process, inconsistent across servers | Use Redis |
Running cron jobs with setInterval | Every server runs the job (duplicates) | Use Bull or SQS |
| Rate limiting with in-memory counters | Each server counts independently (users get Nx the limit) | Use Redis-based rate limiter |
Hardcoding localhost for service URLs | Does not work across machines | Use service discovery or DNS |
Using fs.writeFile for temp data | Local disk is not shared | Use memory buffers or streams |
9. Key Takeaways
- Stateless means "no server-side session state" -- the server holds no per-client data between requests.
- State still exists -- it is just moved to external stores (Redis, S3, databases) that all servers share.
- JWT provides fully stateless auth -- the token contains the user data, no server lookup needed. But revocation is harder.
- Redis is the universal session/cache store -- connect-redis for sessions, ioredis for caching, Bull for job queues.
- Sticky sessions are a code smell -- they indicate stateful design. Fix the root cause, do not add a band-aid.
- WebSockets need special handling -- use Redis pub/sub adapters (socket.io-redis) to broadcast across servers.
- Audit with the checklist -- sessions, files, caching, jobs, rate limiting, WebSocket state. If any are local, externalise them.
- Test by running multiple instances -- if the app works without sticky sessions, it is truly stateless.
Explain-It Challenge
- A developer says "we can't go stateless because we need to store user sessions." Explain why that statement misunderstands what stateless means.
- Draw a diagram showing how three Express servers, one Redis instance, and one S3 bucket work together in a stateless architecture.
- Your application uses sticky sessions and you need to deploy a critical security patch with zero downtime. Explain the problem and the solution.
Navigation: <- 6.5.b -- Load Balancers | 6.5 Overview ->