The Stateless Application Tier

Statelessness is what makes horizontal scaling possible: where the state goes instead, and what quietly makes a service stateful.

Syntaxapp.use(session({ store: new RedisStore({ client }), secret, resave: false, saveUninitialized: false }))

A stateless service keeps nothing in the process that a later request depends on. Every instance can answer every request, so capacity is a matter of starting more instances and an instance can be killed at any moment. This is the property horizontal scaling rests on, and it is also what makes rolling deploys, autoscaling and spot instances safe. The twelve-factor app states it as a rule: processes are stateless and share nothing.

Where the state actually goes

Nothing disappears; it moves somewhere designed to hold it. Session data goes to Redis or into a signed, encrypted cookie. Uploaded files go to object storage. Counters, locks and rate-limit buckets go to Redis. Durable facts go to the database. Scheduled work goes to a queue or to one elected leader. The app tier becomes a pure function of request plus shared state.

What makes a service stateful by accident

The dangerous cases are the ones that still pass tests on one instance. An in-memory session store. An in-process rate limiter or counter. Writing uploads or generated files to local disk. An in-process cache that correctness, rather than only latency, depends on. A cron or interval timer that now runs in every replica. A WebSocket connection whose peer list lives in one process's memory. A long-lived in-memory ID or sequence generator.

The tell

Each of these works perfectly with one instance and fails intermittently with several, in proportion to replica count. That is also why stickiness looks like a fix: it hides the shared-state bug instead of removing it.

Example

Example · javascript
// connect-redis 8 and later export the store by NAME. Version 7 exported it
// as a default, so older code reads: import RedisStore from "connect-redis"
// Both construct the same class; only the import spelling changed.
import { RedisStore } from "connect-redis"; // connect-redis 10.0.0
import session from "express-session";
import { createClient } from "redis";
import express from "express";

const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect().catch(console.error);

const app = express();
// Behind an L7 balancer, so req.secure and req.ip come from X-Forwarded-*.
app.set("trust proxy", 1);

app.use(
  session({
    // Omit `store` and express-session falls back to MemoryStore, whose own
    // startup warning says it leaks memory and will not scale past a single
    // process. That is the single most common accidental statefulness.
    store: new RedisStore({ client: redisClient, prefix: "sess:" }),
    secret: process.env.SESSION_SECRET, // rotate via an array of secrets
    resave: false,            // do not rewrite an unchanged session
    saveUninitialized: false, // do not create a session for anonymous hits
    cookie: {
      httpOnly: true,
      secure: true,
      sameSite: "lax",
      maxAge: 86_400_000, // 24h; RedisStore's own default ttl is 86400s
    },
  })
);

// Any instance can now serve any request, so the target group needs no
// stickiness and a deploy loses nobody's login.

When to use it

  • Getting a service ready to run more than one replica behind a load balancer.
  • Tracking down a bug that only reproduces in production, roughly one request in three.
  • Removing sticky sessions from a target group without logging everyone out.
  • Making a cron-style job safe when it is deployed as part of a scaled-out service.

More examples

The same rate limiter, in-process and shared

This is the shape of almost every accidental-state bug: a Map at module scope. Reach for the shared version as soon as a second replica exists, and note that the fix is not more code but a different owner for the data.

Example · javascript
// STATEFUL: one bucket per process. Four replicas means four times the limit,
// and a deploy resets everyone's quota.
const hits = new Map();
function allowLocal(key, limit = 100) {
  const n = (hits.get(key) ?? 0) + 1;
  hits.set(key, n);
  return n <= limit;
}

// STATELESS: the counter lives in Redis, so the limit is a property of the
// system rather than of one process.
async function allowShared(redis, key, limit = 100, windowSec = 60) {
  const bucket = `rl:${key}:${Math.floor(Date.now() / 1000 / windowSec)}`;
  // One round trip, and EXPIRE is set in the same pipeline so a crash between
  // the two commands cannot leave a key without a TTL.
  const [count] = await redis
    .multi()
    .incr(bucket)
    .expire(bucket, windowSec)
    .exec();
  return count <= limit;
}

A periodic job that must not run in every replica

Use this for small, idempotent periodic work when you do not want a scheduler component. It is a lease, not a guarantee: if work() outlives the TTL, two replicas can overlap, so the job still has to tolerate running twice. For anything that must not double-run, use a queue with a deduplication key.

Example · javascript
// setInterval in a service with six replicas sends six reminder emails.
// A short Redis lease makes one of them the leader for this tick.
async function runOncePerCluster(redis, name, ttlMs, work) {
  // SET key value NX PX ttl - atomic "create only if absent, with expiry".
  // The longhand of the same call is: SET key value EX <seconds> NX
  const token = crypto.randomUUID();
  const won = await redis.set(`lock:${name}`, token, { NX: true, PX: ttlMs });
  if (!won) return false;

  try {
    await work();
  } finally {
    // Release only if we still hold it, so a slow run cannot delete the lock
    // a later holder now owns.
    const current = await redis.get(`lock:${name}`);
    if (current === token) await redis.del(`lock:${name}`);
  }
  return true;
}

setInterval(
  () => runOncePerCluster(redis, "send-reminders", 60_000, sendReminders),
  60_000
);

Moving uploads and derived files off local disk

Local disk is the easiest state to miss, because it is invisible until the instance count changes. The rule that catches it: if deleting this container would lose something a user can still ask for, the container is holding state it should not.

Example · javascript
// STATEFUL: the file exists on exactly one instance. A later request for the
// thumbnail lands on a different pod and 404s; a restart loses it entirely.
// app.post("/upload", multer({ dest: "/tmp/uploads" }).single("file"), ...)

// STATELESS: the bytes go to object storage, the row goes to the database,
// and the derived image is produced by a worker reading the same key.
app.post("/upload", async (req, res) => {
  const key = `uploads/${req.user.id}/${crypto.randomUUID()}`;
  const url = await presignPut(key, req.body.contentType); // see object storage
  await db.media.insert({ key, userId: req.user.id, status: "pending" });
  // The thumbnail job is a message, not a function call on this instance.
  await queue.send({ type: "thumbnail", key });
  res.json({ uploadUrl: url, key });
});

Discussion

  • Be the first to comment on this lesson.