Direct answer
The recommended pattern is to move session state out of the Node.js process entirely and into a shared external store such as Redis, using a session middleware adapter (for example connect-redis with express-session). Every PM2 worker then reads and writes the same Redis keys, so it no longer matters which worker handles a given request. This is the likely correct architecture for your case; the confirmed facts are that PM2 workers have isolated memory spaces and that PM2's built-in round-robin balancer provides no session affinity on its own.
Why round-robin breaks in-memory sessions
PM2 cluster mode uses the Node.js cluster module: a primary process accepts connections and distributes them across workers. With the default round-robin behavior, consecutive requests from the same client can land on different workers. Anything stored in a module-level variable, a Map, or an in-memory cache inside one worker is invisible to the others, so a session created on worker 1 appears to vanish when worker 3 answers the next request.
Sticky sessions solve this by pinning a client to one worker (usually via a cookie or IP hash at a load balancer in front of the app). That works, but it has two weaknesses: if the pinned worker crashes or is reloaded during a deploy, the session is lost anyway, and load distribution becomes uneven. A Redis-backed store removes the affinity requirement entirely — any worker can serve any request, and a worker restart loses nothing. For that reason, the shared store is generally preferred over stickiness; sticky sessions are a fallback when you cannot modify the application.
Minimal integration steps
- Run Redis somewhere all workers can reach (localhost is fine for a single host).
- Install the adapter:
npm install express-session connect-redis redis.
- Create one Redis client and pass it to the store, then mount the middleware before your routes:
const session = require('express-session');
const { createClient } = require('redis');
const RedisStore = require('connect-redis').default;
const client = createClient({ url: 'redis://127.0.0.1:6379' });
client.connect();
app.use(session({
store: new RedisStore({ client, prefix: 'sess:' }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { maxAge: 3600000 }
}));
Because each worker constructs its own client pointing at the same Redis instance, the store is effectively shared. No PM2-specific configuration is required — this works identically in fork and cluster mode.
Verification
- Start the app with
pm2 start app.js -i 4, then send repeated requests with a cookie jar (curl -c jar -b jar http://localhost:3000/whoami) while logging the worker process.pid. The session data should be identical regardless of which pid responds.
- Inspect Redis directly:
redis-cli KEYS 'sess:*' should show one key per session, and redis-cli GET on it should return the serialized session.
- Kill a worker (
pm2 delete or kill a pid) mid-test and confirm the session survives.
Caveats
Two points are worth checking against your environment rather than assuming: PM2 5 and 6 changed some IPC behavior, so confirm your middleware versions are compatible with your PM2 release; and under high concurrency a single Redis client can become a bottleneck — watch for connection or command-queue saturation and size the pool accordingly. If you still see session loss after adding the Redis store, the one diagnostic detail that changes the recommendation is whether requests pass through an additional proxy (nginx, a cloud load balancer) that might be stripping or rewriting the session cookie.