Skip to main content

🗄️ 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 MemoryStore is unsafe for production
  • Trace how a cookie's session ID is looked up in an external store on each request
  • Configure connect-redis with a modern redis v4 client
  • Configure connect-mongo with ttl and touchAfter
  • 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.

sequenceDiagram participant C as Client participant M as express-session participant St as Store (Redis/Mongo) C->>M: Request with Cookie: sid=abc123 M->>M: Verify cookie signature M->>St: GET session "abc123" alt Found and not expired St-->>M: session data M->>M: Attach as req.session else Missing or expired (TTL) St-->>M: nothing M->>M: Start a new empty session end Note over M,St: After the handler, changed sessions are written back M->>St: SET session "abc123" (reset TTL)

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 optionPurpose
clientThe connected redis client instance
prefixKey namespace (default sess:)
ttlSeconds a session lives; defaults to the cookie maxAge
disableTouchSkip 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 optionPurpose
mongoUrlConnection string (or reuse a client via clientPromise)
ttlSession lifetime in seconds
autoRemove'native' uses a Mongo TTL index to purge expired sessions
touchAfterMinimum interval between writes for an unchanged session
crypto.secretOptionally 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.

DatabasePackageNotes
PostgreSQLconnect-pg-simpleLightweight, can auto-create the table, prunes on an interval
MySQLexpress-mysql-sessionConnection pooling, auto table creation, clears expired rows
Any (Sequelize)connect-session-sequelizeWorks 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.

FactorMemoryStoreRedisMongoDBSQL
SpeedFastestVery fastGoodGood
Scales across servers❌ No✅ Excellent✅ Yes✅ Yes
Survives restart❌ NoOptional (persistence)✅ Yes✅ Yes
Setup effortNoneLowLow if already usedLow if already used
Best forLocal dev onlyMost production appsApps already on MongoApps 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 ttl and keep it aligned with the cookie maxAge
  • Use touchAfter (Mongo) / keep disableTouch off thoughtfully (Redis) to control write load
  • Namespace Redis keys with a prefix so 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 ttl is in seconds while cookie maxAge is 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-redis is the default production choice — fast, with built-in TTL expiry
  • MongoDB via connect-mongo fits apps already on Mongo; use touchAfter to cut writes
  • Switching stores only changes the store option — your routes and cookie flags stay the same
  • TTL is in seconds; cookie maxAge is in milliseconds — don't mix them up

📚 Additional Resources

🚀 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 SecurityhttpOnly, 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.