The SaaS app moved from sticky in-memory sessions to Redis-backed sessions six months ago. Three deploys ago the app started losing user sessions every time a node was replaced — failover, maintenance window, scale event. Users got logged out mid-action. The team blamed Redis, then the load balancer, then the client library. The actual cause was three small misalignments compounding: a short connection-pool timeout, session keys that did not share a hash slot with their auth data, and a connect-redis config that did not retry on READONLY errors. Each is a one-line fix once you see them.
What’s happening
A Redis session store has four moving parts that must agree.
Connection pool. The app server holds a TCP connection (or pool) to Redis. Connections survive across requests. During a failover, those TCP connections are dead — the primary moved. The client must detect the dead connection fast, reconnect to the new primary (often via the same DNS endpoint), and resume. Default connection timeouts in many clients are 30 seconds. With a 30-second connect timeout and a 60-second client timeout, a failover can lock up app workers for over a minute, which propagates upstream as 504 timeouts.
TTL alignment. Sessions have an expiration time — say 24 hours rolling. The auth token (JWT, cookie) also has a lifetime. If the auth cookie outlives the Redis session, the user presents a valid cookie but the server can no longer find the matching session — logged out. If the Redis session outlives the cookie, you waste memory on dead sessions. The rolling pattern (EXPIRE on each access) is correct only if the cookie also has a sliding lifetime that matches.
Key namespacing. A single Redis can hold sessions for multiple environments, tenants, or apps. Without a namespace, a staging session can collide with production. With cluster mode, the namespace also has to play with hash slots — multi-tenant key patterns like sess:tenant:{tid}:user:{uid} should hash all related keys for one tenant to the same shard, or MULTI/EXEC ops across keys for one session will fail.
Failover behavior. During automatic failover, writes are rejected for a few seconds. Clients receive READONLY You can't write against a read only replica if they happen to hit the old primary now demoted to replica, or connection-refused on the brief window the new primary is being promoted. The client must retry — and retry with backoff, not in a tight loop that hammers the cluster while it stabilizes. A library that does not retry surfaces the failover as user-visible 500s.
The pattern that works is the four parts aligned: pool with a short connect timeout, TTL matched to auth lifetime, namespaced keys with hash tags where multi-key ops happen, and a client that retries READONLY and connection errors with backoff.
How to verify
Test the four parts independently before trusting the whole stack.
Connection pool test. Connect to Redis from the app server and check the connection pool config in the client library.
For ioredis (Node):
const Redis = require('ioredis');
const redis = new Redis({
host: 'saas-prod.abc123.ng.0001.use1.cache.amazonaws.com',
port: 6379,
connectTimeout: 5000,
commandTimeout: 3000,
enableReadyCheck: true,
maxRetriesPerRequest: 3,
retryStrategy: (times) => Math.min(times * 200, 2000),
reconnectOnError: (err) => {
const targetError = 'READONLY';
if (err.message.includes(targetError)) return true;
return false;
},
});
The four pieces that matter: connectTimeout: 5000 (5s, not the default 10s), commandTimeout: 3000 (so a hung command does not lock a worker), maxRetriesPerRequest: 3 (so transient failures retry), and reconnectOnError returning true on READONLY (so the client drops the connection and reconnects to the new primary).
For phpredis (PHP):
$redis = new Redis();
$redis->connect(
'saas-prod.abc123.ng.0001.use1.cache.amazonaws.com',
6379,
2.0, // connect timeout (seconds)
null,
100, // retry interval (ms)
1.0 // read timeout (seconds)
);
$redis->setOption(Redis::OPT_TCP_KEEPALIVE, 60);
PHP-FPM workers do not naturally pool — each request opens a connection unless you use pconnect. pconnect reuses the connection across requests within the same FPM worker, which is what you want. The cost is that on failover, the persistent connection must be reset.
TTL alignment check. For a session with 24-hour rolling expiration, the cookie’s Max-Age and the Redis key’s TTL should match.
redis-cli -h saas-prod.abc123.ng.0001.use1.cache.amazonaws.com \
TTL sess:abc123
The output should be near 86400 (24 hours) right after a login, and refreshed near 86400 after each request. If you see 3600 (one hour), the session store is rewriting the TTL on every save with a short value — the cookie outlives the session.
Failover test. Trigger a planned failover and watch the app:
aws elasticache test-failover \
--replication-group-id saas-prod \
--node-group-id 0001
Run a steady load against the app — wrk or vegeta doing one request per second per worker, all of them touching the session. The expected behavior is a 5–15-second window where ~1% of requests hit a READONLY or ECONNREFUSED, the client retries, and the request completes on the new primary. If the app surfaces those as 500s to the user, the client retry config is wrong.
The fix
The end-to-end working pattern for a Node app with connect-redis:
const Redis = require('ioredis');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const redis = new Redis({
host: process.env.REDIS_HOST,
port: 6379,
connectTimeout: 5000,
commandTimeout: 3000,
maxRetriesPerRequest: 3,
retryStrategy: (times) => Math.min(times * 200, 2000),
reconnectOnError: (err) => err.message.includes('READONLY'),
keyPrefix: `sess:${process.env.ENV}:`,
});
app.use(session({
store: new RedisStore({
client: redis,
prefix: 'sess:',
ttl: 86400,
disableTouch: false,
}),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
rolling: true,
cookie: {
maxAge: 86400 * 1000,
httpOnly: true,
secure: true,
sameSite: 'lax',
},
}));
Three things to note. keyPrefix namespaces the cluster by environment so staging and production can share a Redis (they should not, but if they do the keys do not collide). rolling: true plus disableTouch: false and matched ttl and cookie.maxAge keeps the cookie and the session expiring at the same time. The reconnectOnError callback handles the read-only-replica error path during failover.
For iron-session (stateless JWT-style cookies with optional Redis backing for revocation):
import { getIronSession } from 'iron-session';
const sessionOptions = {
password: process.env.IRON_SESSION_PASSWORD,
cookieName: 'app_session',
cookieOptions: {
secure: true,
httpOnly: true,
sameSite: 'lax',
maxAge: 86400,
},
};
iron-session does not need Redis at all if your session is small and you accept that “log out everywhere” is a cookie-expiry problem rather than a server-side invalidate. If you need server-side revocation (a “log out all devices” feature), pair iron-session with a Redis revocation set: on logout, SADD revoked:user:<uid> <jti> and on each request check SISMEMBER. The revocation set has its own TTL matching the longest token lifetime.
For cluster-mode ElastiCache, the session key construction matters. Use sess:{<sid>}: so all keys related to a session land on the same shard. If you also store auth state (auth:{<sid>}:profile), the hash tag aligns them — the MULTI/EXEC block that connect-redis issues during session write will work.
The last check is connection lifecycle on app restart. App restarts under load should not flood Redis with new connections in a stampede. ioredis does this naturally — connections are created lazily and reused. phpredis with pconnect does this naturally per FPM worker. The thing to avoid is opening a fresh connection on every request, which on FPM means avoiding connect in favor of pconnect.
One last lesson worth carrying: do not put cache and sessions on the same Redis without thinking about eviction. ElastiCache nodes evict under volatile-lru or allkeys-lru by default depending on parameter group — if a session key happens to be selected for eviction during memory pressure, the user is logged out. The defensive pattern is a separate replication group for sessions (with noeviction or volatile-lru) and a separate replication group for the throwaway response cache (with allkeys-lru). The two have very different operational characteristics — never share a node.
Stack Harbor designs Redis topologies as part of cluster-tier work in Managed AWS and the HA Clusters operating layer. If your app still loses sessions every time the cache restarts, start with an infrastructure consult.