🗄️ Session Stores
Your sessions work — but they live in a plain object inside one Node process. Restart the server and everyone's logged out; add a second server and half your users vanish. In this lesson you'll give sessions a proper home: a shared, persistent store like Redis or MongoDB that survives restarts and scales across machines.
Week 9 · Day 3 (Wednesday: Session-based Authentication) · Lecture 2
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain why the default
MemoryStoreis unsafe for production - Trace how a cookie's session ID is looked up in an external store on each request
- Configure
connect-rediswith a modernredisv4 client - Configure
connect-mongowithttlandtouchAfter - Choose between Redis, MongoDB, and SQL stores for a given workload
- Keep session cookie security intact when the store is external
Estimated Time: 70 minutes
Practice: Swap a MemoryStore app to Redis, then to Mongo, and confirm sessions survive a restart.
In This Lesson
Why a Store Matters
Recall the coat-check analogy from the last lesson: the browser holds a ticket (the session ID) and the real data hangs on a hook behind the counter. The session store is that counter. Everything about your app's reliability under load comes down to which counter you pick.
📖 Sticky notes vs. a filing cabinet
The default MemoryStore is like scribbling every coat's location on a sticky note pressed to one attendant's forehead. It's instant to write, but the notes vanish when that attendant goes home (server restart), and a second attendant at another door has no idea what the first one wrote (second server). A real store — Redis or MongoDB — is a shared filing cabinet every attendant can read and write, and it doesn't forget when someone clocks out.
The good news: switching stores changes exactly one thing in your config — the store option. Every route you wrote in the last lesson keeps working untouched, because req.session is the same API no matter where the data is persisted.
The Cookie → Store Lookup
On every authenticated request, the middleware performs the same dance. Understanding it tells you exactly why store latency matters and where a TTL comes in.
Two things to notice. First, that GET happens on every request, so the store needs low latency — this is why Redis, an in-memory data store, is so popular. Second, the store enforces expiry itself via a TTL (time to live). When the TTL elapses, the store drops the session automatically, so stale sessions clean themselves up without a cron job.
The MemoryStore Trap
If you omit the store option, express-session uses a built-in MemoryStore. It's perfect for a five-minute demo and disqualifying for production.
⚠️ Four reasons MemoryStore fails in production
- Leaks memory — it never fully reclaims expired sessions, so RAM climbs until the process dies
- Loses everything on restart — a deploy or crash logs out every user
- Not shared — a second server or worker can't see the first one's sessions
- Prints a warning — it literally logs "MemoryStore is not designed for a production environment"
💡 A concrete failure
Your app gets featured on a popular newsletter and 10,000 visitors arrive in an hour. At ~5 KB each that's ~50 MB of never-freed session data. You deploy a small fix that evening — and all 10,000 people are logged out mid-checkout. Add a second server behind a load balancer and requests bounce between two processes that don't share sessions, so users appear to log in and out at random.
The rest of this lesson is the fix: point store at Redis or MongoDB.
Redis with connect-redis
Redis is the most common choice: it's an in-memory key-value store built for exactly this — small values read and written constantly, with built-in expiry. Modern connect-redis (v6+) pairs with the redis v4 client.
Install & configure
npm install express-session connect-redis redis
// redis-session.js
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const { RedisStore } = require('connect-redis'); // v6+ named export
const { createClient } = require('redis');
const app = express();
// 1) Create and connect the Redis client (v4 API returns promises).
const redisClient = createClient({ url: process.env.REDIS_URL }); // e.g. redis://localhost:6379
redisClient.on('error', (err) => console.error('Redis error:', err));
redisClient.connect().catch(console.error);
// 2) Build the store from the connected client.
const store = new RedisStore({
client: redisClient,
prefix: 'sess:', // keys look like sess:abc123 — easy to spot & scope
ttl: 60 * 60 // seconds the session lives in Redis (1 hour)
});
// 3) Hand the store to express-session — cookie flags stay exactly as before.
app.use(session({
store,
name: 'sid',
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 1000 * 60 * 60
}
}));
app.get('/', (req, res) => {
req.session.views = (req.session.views || 0) + 1;
res.send(`Views: ${req.session.views}`);
});
app.listen(3000, () => console.log('Redis-backed sessions on :3000'));
✅ Prove it survives a restart
Hit the page a few times, stop the server with Ctrl+C, start it again, and reload. The counter keeps going — because the session lives in Redis, not in the Node process. That's the whole point.
| RedisStore option | Purpose |
|---|---|
client | The connected redis client instance |
prefix | Key namespace (default sess:) |
ttl | Seconds a session lives; defaults to the cookie maxAge |
disableTouch | Skip resetting TTL on reads — fewer writes, but idle sessions expire sooner |
💡 Inspecting sessions from the CLI
redis-cli
KEYS sess:* // list every session key (fine for dev; avoid on huge prod sets)
GET sess:abc123 // view one session's JSON
TTL sess:abc123 // seconds left before it expires
DEL sess:abc123 // force-logout a single session
MongoDB with connect-mongo
If your app already runs MongoDB, storing sessions there avoids adding another piece of infrastructure. connect-mongo (v4+) uses a factory method, MongoStore.create().
npm install express-session connect-mongo
// mongo-session.js
require('dotenv').config();
const express = require('express');
const session = require('express-session');
const MongoStore = require('connect-mongo');
const app = express();
app.use(session({
store: MongoStore.create({
mongoUrl: process.env.MONGODB_URI, // mongodb://localhost:27017/app
collectionName: 'sessions', // where sessions are stored
ttl: 14 * 24 * 60 * 60, // 14 days, in seconds
autoRemove: 'native', // let Mongo's TTL index expire them
touchAfter: 24 * 3600 // only re-write an unchanged session once/day
}),
name: 'sid',
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 14 * 24 * 60 * 60 * 1000 // 14 days, in milliseconds
}
}));
💡 touchAfter is the write-saver
Without it, every single request re-writes the session just to bump its expiry — a lot of database load for no real change. touchAfter: 24 * 3600 says "if nothing in the session actually changed, don't re-write it more than once a day." Big performance win on read-heavy apps.
| MongoStore option | Purpose |
|---|---|
mongoUrl | Connection string (or reuse a client via clientPromise) |
ttl | Session lifetime in seconds |
autoRemove | 'native' uses a Mongo TTL index to purge expired sessions |
touchAfter | Minimum interval between writes for an unchanged session |
crypto.secret | Optionally encrypt stored session data at rest |
✅ Reuse an existing connection
If you already have a Mongoose connection, don't open a second one. Pass its client instead:
const mongoose = require('mongoose');
MongoStore.create({
clientPromise: mongoose.connection.asPromise().then(c => c.getClient()),
collectionName: 'sessions'
});
SQL Stores
Already on PostgreSQL or MySQL and don't want to run Redis? There are battle-tested stores for SQL too. They're a touch slower than an in-memory store but keep your infrastructure count low.
| Database | Package | Notes |
|---|---|---|
| PostgreSQL | connect-pg-simple | Lightweight, can auto-create the table, prunes on an interval |
| MySQL | express-mysql-session | Connection pooling, auto table creation, clears expired rows |
| Any (Sequelize) | connect-session-sequelize | Works with any Sequelize-supported database |
// PostgreSQL example with connect-pg-simple
const session = require('express-session');
const pgSession = require('connect-pg-simple')(session);
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
app.use(session({
store: new pgSession({
pool,
tableName: 'session',
createTableIfMissing: true, // creates the table for you on first run
pruneSessionInterval: 60 * 15 // sweep expired rows every 15 minutes
}),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' }
}));
Choosing a Store
There's no universal winner — match the store to your traffic and the infrastructure you already run.
| Factor | MemoryStore | Redis | MongoDB | SQL |
|---|---|---|---|---|
| Speed | Fastest | Very fast | Good | Good |
| Scales across servers | ❌ No | ✅ Excellent | ✅ Yes | ✅ Yes |
| Survives restart | ❌ No | Optional (persistence) | ✅ Yes | ✅ Yes |
| Setup effort | None | Low | Low if already used | Low if already used |
| Best for | Local dev only | Most production apps | Apps already on Mongo | Apps already on SQL |
Rule of thumb: reach for Redis by default in production — it's purpose-built for short-lived key-value data with expiry. Use Mongo or SQL when you already run one and want to avoid a new dependency. Use MemoryStore only on your laptop.
Practice & Quiz
🏋️ Exercise 1: Redis-back an existing app
Goal: Take the MemoryStore visit-counter from the previous lesson and move it to Redis. Then restart the server and confirm the count persists.
💡 Hint
Create and connect() a redis v4 client, build a RedisStore from it, and pass it as store. Change nothing else — the routes stay identical.
✅ Solution
const { RedisStore } = require('connect-redis');
const { createClient } = require('redis');
const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect().catch(console.error);
app.use(session({
store: new RedisStore({ client: redisClient, prefix: 'sess:', ttl: 3600 }),
name: 'sid',
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', maxAge: 3600000 }
}));
🏋️ Exercise 2: Cut Mongo write load
Goal: A Mongo-backed app re-writes the session on every request, hammering the database. Adjust the config so an unchanged session is only re-written once per day, and let Mongo purge expired sessions itself.
✅ Solution
MongoStore.create({
mongoUrl: process.env.MONGODB_URI,
ttl: 14 * 24 * 60 * 60,
autoRemove: 'native', // Mongo TTL index removes expired sessions
touchAfter: 24 * 3600 // skip re-writes unless a day has passed or data changed
});
🎯 Quick Quiz
Question 1: Why is the default MemoryStore unsuitable for production?
Question 2: What does a store's ttl control?
Question 3: When you switch from MemoryStore to Redis, what must change in your route handlers?
Best Practices & Pitfalls
✅ Do
- Use Redis (or Mongo/SQL) in every non-local environment — never MemoryStore
- Set a sensible
ttland keep it aligned with the cookiemaxAge - Use
touchAfter(Mongo) / keepdisableTouchoff thoughtfully (Redis) to control write load - Namespace Redis keys with a
prefixso sessions are easy to scope and flush - Store the connection string in
process.env, and enable TLS/auth on the store - Keep session payloads tiny — ids and flags, not whole user documents
❌ Don't
- Ship MemoryStore and wonder why users get logged out after every deploy
- Open a second Mongo connection when you can reuse an existing client
- Expose Redis/Mongo to the public internet — put them on a private network
- Forget that
ttlis in seconds while cookiemaxAgeis in milliseconds
⚠️ Version-API mismatch
Old tutorials show const RedisStore = require('connect-redis')(session) and a callback-style redis.createClient(). That's the v3 API. On modern connect-redis (v6+) with redis v4, import { RedisStore } and await client.connect(). Mixing the two produces confusing "client closed" errors.
Summary
🎉 Key Takeaways
- The default MemoryStore leaks, forgets on restart, and can't be shared — never use it in production
- The session ID cookie is looked up in the store on every request, so store latency matters
- Redis via
connect-redisis the default production choice — fast, with built-in TTL expiry - MongoDB via
connect-mongofits apps already on Mongo; usetouchAfterto cut writes - Switching stores only changes the
storeoption — your routes and cookie flags stay the same - TTL is in seconds; cookie
maxAgeis in milliseconds — don't mix them up
📚 Additional Resources
- Express — compatible session stores
- connect-redis on GitHub
- connect-mongo on GitHub
- OWASP — Session Management Cheat Sheet
🚀 What's Next?
Your sessions are now fast, persistent, and shared. The last piece is hardening the one thing the client does hold — the cookie. Next: Cookie Security — httpOnly, secure, SameSite, cookie prefixes, and CSRF defense.
🎉 Production-grade sessions!
Restart-proof, multi-server, and self-expiring. Now let's lock down the cookie that ties it all together.