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

StatefulStateless
State livesOn 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 impactActive sessions lostNothing lost (state is external)
Rolling deployRisky (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

  1. Auto-scaling -- add servers during peak, remove during off-peak. No session draining needed.
  2. Zero-downtime deployments -- deploy new code to fresh servers, shift traffic, kill old servers.
  3. Multi-region deployment -- replicate the same stateless app in multiple AWS regions behind a global load balancer.
  4. Container orchestration -- Kubernetes can kill, restart, and reschedule pods freely because pods hold no state.
  5. 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

FactorJWTRedis Sessions
Server-side storageNone (fully stateless)Redis (shared external store)
RevocationHard (token is valid until expiry)Easy (delete from Redis)
Token sizeLarger (payload in every request)Small (just a session ID cookie)
PerformanceNo network hop for authRedis lookup per request
Best forAPIs, mobile apps, microservicesWeb apps that need instant logout/revocation
LogoutClient 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

ProblemExplanation
Uneven loadSome servers accumulate more sessions; others sit idle
Session loss on failureServer dies = all sessions on it are gone
Cannot scale inRemoving a server means terminating its users' sessions
No spot instancesCannot use cheap spot instances that may be reclaimed
Rolling deploys are riskyMust drain sessions before killing old servers
New servers are coldFreshly launched servers get no existing sessions

When sticky sessions are acceptable

  1. Short-term migration -- you are actively refactoring toward stateless but need something that works today.
  2. WebSocket connections -- a WebSocket is a persistent TCP connection to a specific server (though you should still store shared state externally).
  3. 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:

CheckQuestionStateless Answer
SessionsWhere is session data stored?Redis or JWT (not in-memory)
AuthHow is the user identified?JWT token or Redis-backed session cookie
File storageWhere are uploaded files saved?S3 or equivalent (not local disk)
CachingWhere is cached data stored?Redis or Memcached (not process memory)
Temp filesAre temp files written to disk?Use memory buffers or stream to S3
ConfigIs config stored in the server?Environment variables or config service
WebSocket stateIs WS connection state local?Use Redis pub/sub adapter
Scheduled jobsAre cron jobs running on one server?Use a distributed job queue (Bull, SQS)
Rate limitingIs the rate limit counter local?Use Redis-based rate limiting
Server identityDoes 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

MistakeWhy It Breaks ScalingFix
Storing sessions in MemoryStoreSessions lost on crash, unavailable on other serversUse Redis store
Saving uploads to local diskFiles only exist on one serverUse S3
Using global variables for cacheCache is per-process, inconsistent across serversUse Redis
Running cron jobs with setIntervalEvery server runs the job (duplicates)Use Bull or SQS
Rate limiting with in-memory countersEach server counts independently (users get Nx the limit)Use Redis-based rate limiter
Hardcoding localhost for service URLsDoes not work across machinesUse service discovery or DNS
Using fs.writeFile for temp dataLocal disk is not sharedUse memory buffers or streams

9. Key Takeaways

  1. Stateless means "no server-side session state" -- the server holds no per-client data between requests.
  2. State still exists -- it is just moved to external stores (Redis, S3, databases) that all servers share.
  3. JWT provides fully stateless auth -- the token contains the user data, no server lookup needed. But revocation is harder.
  4. Redis is the universal session/cache store -- connect-redis for sessions, ioredis for caching, Bull for job queues.
  5. Sticky sessions are a code smell -- they indicate stateful design. Fix the root cause, do not add a band-aid.
  6. WebSockets need special handling -- use Redis pub/sub adapters (socket.io-redis) to broadcast across servers.
  7. Audit with the checklist -- sessions, files, caching, jobs, rate limiting, WebSocket state. If any are local, externalise them.
  8. Test by running multiple instances -- if the app works without sticky sessions, it is truly stateless.

Explain-It Challenge

  1. A developer says "we can't go stateless because we need to store user sessions." Explain why that statement misunderstands what stateless means.
  2. Draw a diagram showing how three Express servers, one Redis instance, and one S3 bucket work together in a stateless architecture.
  3. 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 ->