Skip to content
M02State and side effectsLesson 1 of 4

Where state lives

Map every piece of state in the system, know which copy is the source of truth and keep application processes stateless.

Goal
Point to where each piece of data in your system lives, which copy is the source of truth and what happens to it on a restart or with two instances.

Linkr limits free-plan users to 10 new links per minute. The AI-generated implementation is simple and passes every test:

// ❌ State in process memory
const hits = new Map<string, { count: number; resetAt: number }>();
export function rateLimit(userId: string): boolean {
const now = Date.now();
const entry = hits.get(userId);
if (!entry || entry.resetAt < now) {
hits.set(userId, { count: 1, resetAt: now + 60_000 });
return true;
}
entry.count++;
return entry.count <= 10;
}

In production, with three instances behind the load balancer, each one has its own Map. A user can create 30 links per minute. Every deploy resets the counters. And the Map never removes old entries, so memory keeps growing until the process restarts.

The counter logic is correct. The problem is where it keeps its data: in each process’s memory.

State is any information that survives from one operation to the next. In a typical web system it’s spread across many places:

flowchart TB
  subgraph Client
    U[URL] ~~~ LS[localStorage<br/>cookies] ~~~ RM[Tab state]
  end
  subgraph Application
    I1[Instance 1<br/>memory] ~~~ I2[Instance 2<br/>memory]
  end
  subgraph Shared
    R[(Redis)] ~~~ Q[[Queue]] ~~~ DB[(Postgres<br/>source of truth)] ~~~ S3[(Storage)]
  end
  EXT[Stripe and other<br/>providers]
  Client -->|via CDN| Application
  Application --> Shared
  Application --> EXT

Ask four questions about each place:

Place Shared across instances? Survives a restart? Who can change it? Typical use
Process memory No No Only that process Small local cache, open connections
Redis Yes Depends on configuration Any instance Cache, sessions, rate limits, locks
Postgres Yes Yes Any instance, with transactions Source of truth
Queue Yes Yes (mostly) Producers and consumers Pending work
Client (browser) No Yes, until the user clears it The user Preferences, drafts
External provider Yes Yes The provider and you Payments, email

Two rules come out of this table:

  1. Anything that comes from the client may have been tampered with. Never accept a price, a user role or a plan ID from the browser without checking it on the server.
  2. Process memory is disposable. It isn’t shared, and it’s wiped on every deploy, crash or autoscaling event.

The same piece of data often exists in several copies: the user’s plan is in Postgres, in the Redis cache, in the session JWT and in Stripe. Only one copy can be the source of truth. The others are derived and may be stale.

For each important piece of data, decide explicitly:

  • Which copy is the source of truth? For payment status it’s Stripe, and your database holds a copy updated by webhook. For links it’s Postgres.
  • How stale can a copy get? A click counter can lag by a minute. The permissions of a banned user can’t.
  • How is the copy updated or invalidated? By TTL, by event, or by re-reading the source.

State bugs are almost always a derived copy being treated as the source.

The goal is for any instance to be able to serve any request, and for killing an instance to lose nothing. To get there, state moves out of memory into shared storage.

The rate limiter, rebuilt on Redis:

// ✅ Counter shared across instances, with automatic expiry
import { redis } from './redis';
export async function rateLimit(userId: string, limit = 10, windowSec = 60): Promise<boolean> {
const window = Math.floor(Date.now() / 1000 / windowSec);
const key = `rl:create-link:${userId}:${window}`;
// INCR is atomic: two instances never read the same value
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, windowSec);
return count <= limit;
}

This is a fixed window, which is good enough in most cases. More precise algorithms (sliding window, token bucket) come up in M13.

Other changes that follow the same logic:

  • Sessions: in Redis or the database, out of memory (express-session with MemoryStore is for development only, and its own docs say so).
  • Uploads: straight to object storage (S3, R2), never to the instance’s local disk.
  • Scheduled jobs: in a queue or behind a database lock, instead of a setInterval that runs on every instance (lesson 2.3).
  • Unbounded in-memory cache. A Map used as a cache grows forever. If you need a local cache, use an LRU with a maximum size and a TTL.
  • Module-level global variables holding request state. In Node, a module is shared by every request in that process. A let currentUser at the top of a file leaks data between users.
  • Trusting the JWT as the source of truth. It’s a signed copy of the state at the moment it was issued. If the user lost access, the token keeps saying they have it until it expires.
  • What AI tends to generate: global Maps and arrays used as a “database”, rate limits and sessions in memory, files written to ./uploads.

Go deeper

Materials in English unless noted.

  • Article
    Twelve-Factor: Processes12factor.net

    Why processes should be stateless and share nothing.

  • Docs
    INCR (rate limiter pattern)Redis

    The command's docs include several rate limiter variations built on Redis.

  • Docs
    express-sessionnpm

    See the warning about MemoryStore and the list of production-ready stores.

  • Tool
    lru-cachenpm

    A local cache with a maximum size and TTL, for when an in-memory cache makes sense.