Where state lives
Map every piece of state in the system, know which copy is the source of truth and keep application processes stateless.
The problem
Section titled “The problem”Linkr limits free-plan users to 10 new links per minute. The AI-generated implementation is simple and passes every test:
// ❌ State in process memoryconst 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 anything that remembers
Section titled “State is anything that remembers”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:
- 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.
- Process memory is disposable. It isn’t shared, and it’s wiped on every deploy, crash or autoscaling event.
Source of truth and derived state
Section titled “Source of truth and derived state”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.
In practice: stateless processes
Section titled “In practice: stateless processes”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 expiryimport { 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-sessionwithMemoryStoreis 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
setIntervalthat runs on every instance (lesson 2.3).
Pitfalls
Section titled “Pitfalls”- Unbounded in-memory cache. A
Mapused 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 currentUserat 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
- DocsINCR (rate limiter pattern)Redis
The command's docs include several rate limiter variations built on Redis.
- Docs
- Tool