Engineering Practice
Know How Long Your State Lives
Every variable has a lifetime — this request, this session, the whole process, or a cache somewhere — and most state bugs come from treating one lifetime as if it were another.
- Engineering Practice
- Architecture
- State Management
- Debugging
I learned this lesson the hard way inside a rules engine that namespaces all of its state explicitly — every value you touch is tagged with where it lives and how long it lasts. At first that verbosity felt like ceremony. Then I realized it was preventing an entire class of bug by forcing a question most languages let you ignore: how long does this value live? Per request? Per session? For the life of the process? In a cache that outlives all of them? Get that wrong and you get bugs that are maddening precisely because the code looks correct.
Every value has a lifetime
When you write x = something, you’re implicitly choosing a lifetime for x, even if the language never makes you say it. A local in a request handler dies when the request ends. A field on a long-lived object lives as long as that object. A module-level global lives for the whole process. A cached value lives until something evicts it. These are wildly different durations, and the language often makes them look identical — x = something either way.
The engine I worked in refused that ambiguity. Request data, per-transaction scratch space, process-wide globals loaded at startup, parsed values held in a cache, the outgoing reply — each lived in a clearly named scope with a clearly defined lifetime. You could not accidentally confuse “scratch space for this one request” with “a global shared across every request,” because they didn’t even look alike. That distinction, made visible, is a feature.
The dangerous confusion: per-request vs shared
The specific mistake that scope discipline prevents is the worst one in any concurrent system: treating shared state like it’s private. You stash something in a place that feels local, but it’s actually shared across every request the process handles. It works perfectly with one user. Under real traffic, requests start stepping on each other’s values, and you get answers that are subtly, intermittently wrong.
The bug that ruins your week isn’t a crash. It’s two requests quietly sharing a variable that one of them thought it owned.
These bugs are brutal because they don’t reproduce on your machine, they don’t show up in a unit test with one caller, and the code reads as obviously correct. The only durable defense is to know — explicitly, not vaguely — whether a piece of state is per-request or shared, before you write to it. A system that makes you name the scope makes that mistake hard to commit by accident.
Globals are fine until they aren’t
Process-wide globals aren’t evil. Configuration loaded once at startup genuinely should live for the life of the process — re-reading it per request would be wasteful and pointless. The danger isn’t globals; it’s mutable globals touched on the request path, where the “global” part means “shared across everything happening at once.”
So the test I apply: is this global effectively read-only after startup? Then it’s fine, and treating it as long-lived is correct. Does anything write to it while requests are in flight? Then it’s a shared mutable, and it needs the scrutiny you’d give any concurrency hazard. The lifetime tells you which conversation you’re having. “Global config” and “global mutable counter” are spelled the same and behave nothing alike.
Caches are a lifetime you chose without noticing
Caches are where lifetime confusion hides best, because a cached value’s lifetime is “until eviction,” and eviction is usually invisible at the call site. You read from the cache as if the value is fresh and request-scoped; in reality it might be stale data from a different context entirely, living far longer than you assumed.
This is the source of the classic “I fixed it but it’s still broken” afternoon — you corrected the real value, but something is serving a cached copy with its own, longer lifetime. Whenever behavior doesn’t match what you just changed, “what’s the lifetime of the thing I’m reading, and is it older than my change?” is the question that cracks it. A cache is a deliberate decision to let a value outlive its source; treat it like one, including recomputing on the hot path only when you’ve actually proven the cache is the win.
Make the lifetime explicit
You don’t need a special engine to get the benefit. You can adopt the discipline anywhere:
- Name the scope in your head before you assign. Request, session, process, cache — pick one on purpose, not by where the variable happened to be convenient.
- Be suspicious of any mutable that outlives a request. If it’s shared and writable on the request path, it’s a concurrency question, not a convenience.
- Treat cache reads as time travel. The value came from some other moment; decide whether that’s acceptable here.
- When something’s intermittently wrong, audit lifetimes first. Most “impossible” state bugs are a lifetime mismatch wearing a disguise.
The engine that forced me to label every variable’s lifetime was annoying for a week and clarifying forever. It’s the same family of thinking as treating a config language as real code: the more explicit a system is about when and where a value exists, the fewer ways it leaves for you to fool yourself. If you’ve got a state bug that only shows up under load, that’s usually a lifetime in disguise — happy to compare notes.