Your Backend Is Already a State Machine
Most backend systems don’t have a state machine. They have a status column, a few booleans bolted on over time, some if statements that grew organically, and a worker somewhere that updates the status when it feels like it. Eventually nobody on the team is completely sure which transitions are actually allowed — you just kind of hope the code paths that exist are the only ones that matter.
Take something as basic as an LLM generation request:
queued
↓
generating
↓
streaming
↓
completed
That’s already a state machine. You don’t need a library for it. The system has states and transitions whether you write them down or not — the only real question is whether those transitions live somewhere you can actually see them.
Where it starts getting messy
Say the database starts with:
status: "generating"
Fine. Then you add cancellation:
status: "generating"
cancelled: true
Then failure handling:
status: "failed"
error: "..."
Then retries, because of course:
status: "generating"
retryCount: 2
Then streaming support:
status: "generating"
isStreaming: true
None of these fields are unreasonable on their own — each one made sense the day someone added it. The problem is they’re all quietly describing the same lifecycle independently, and nothing stops them from disagreeing with each other. So what happens when you end up with this in your database?
status = "completed"
isStreaming = true
cancelled = true
The database stores it without complaint. Your type system probably accepts it too. The business logic definitely shouldn’t — but by the time you notice, it’s already three services downstream causing weird bugs that only show up in production.
A state is more useful than a pile of flags
Instead of scattering independent properties around, you define the actual states the thing can be in:
type GenerationState =
| "queued"
| "generating"
| "streaming"
| "completed"
| "failed"
| "cancelled";
Now the lifecycle is explicit, and failure paths branch off cleanly instead of getting bolted on as afterthoughts:
generating ─────→ failed
streaming ─────→ cancelled
failed ─────→ queued
What you get out of this is a finite set of states the system is allowed to occupy. That’s already better than a pile of booleans. But it’s only half the job — knowing a request can be completed doesn’t tell you whether it can go back to generating. You need the transitions too:
Current state + Event → Next state
queued + START_GENERATION → generating
generating + TOKEN_RECEIVED → streaming
streaming + GENERATION_DONE → completed
generating + GENERATION_ERROR → failed
streaming + CANCEL → cancelled
failed + RETRY → queued
Once you have that, you can finally write down what’s not allowed, instead of relying on someone remembering not to do it:
completed + START_GENERATION → ❌
cancelled + TOKEN_RECEIVED → ❌
failed + GENERATION_DONE → ❌
Enforcing it in code
Writing the table down is nice, but it’s not worth much until something actually checks it. In practice this can be as small as a lookup map and a guard function — nothing fancy, no library required:
type Event =
| "START_GENERATION"
| "TOKEN_RECEIVED"
| "GENERATION_DONE"
| "GENERATION_ERROR"
| "CANCEL"
| "RETRY";
const transitions: Record<GenerationState, Partial<Record<Event, GenerationState>>> = {
queued: { START_GENERATION: "generating" },
generating: { TOKEN_RECEIVED: "streaming", GENERATION_ERROR: "failed" },
streaming: { GENERATION_DONE: "completed", CANCEL: "cancelled" },
completed: {},
failed: { RETRY: "queued" },
cancelled: {},
};
function applyTransition(current: GenerationState, event: Event): GenerationState {
const next = transitions[current][event];
if (!next) {
throw new Error(`Invalid transition: ${current} + ${event}`);
}
return next;
}
That’s it — a plain object and a function that throws when someone tries something the lifecycle doesn’t allow. You don’t need XState or a framework to get most of the benefit. The value isn’t in the tooling, it’s in having one place where “is this allowed” gets decided, instead of that logic being smeared across five files.
This is where it gets genuinely distributed
The LLM call itself isn’t the interesting part of the system. It’s basically:
const response = await llm.generate(prompt);
The lifecycle wrapped around that call is where things get real. A request enters a queue, gets picked up by a worker, starts generating, streams tokens down to a client, maybe times out, maybe gets retried, maybe gets cancelled mid-stream by an impatient user, and eventually needs its final state written somewhere durable. Multiple components touch this thing:
┌──────────┐
│ API │
└────┬─────┘
↓
┌──────────┐
│ Queue │
└────┬─────┘
↓
┌──────────┐
│ Worker │
└────┬─────┘
↓
┌──────────┐
│ LLM │
└──────────┘
Each of these can observe or cause a state change, and that’s exactly where implicit state machines fall apart. The API thinks the request is cancelled. The worker thinks it’s still generating. The database says completed. The client stopped getting tokens five seconds ago and has no idea why. Nothing is broken at the level of any individual function — the system is broken because there’s no single source of truth for what the request is even allowed to be doing right now.
Writing the state machine down doesn’t fix distributed systems
Worth being blunt about this part. Dropping an enum into your codebase doesn’t give you correctness for free:
enum Status {
QUEUED,
GENERATING,
COMPLETED
}
You still have concurrency to deal with. Two workers can grab the same job. A transaction can fail halfway through. A network call can time out on your end after the remote system already finished processing it. A user can hit cancel at exactly the worst possible millisecond. The state machine gives you the model — a shared, unambiguous description of what states exist and how you get between them. It doesn’t enforce itself. That part is still on you: database constraints, transactions, optimistic locking, idempotency keys, whatever the workflow actually needs.
Where this is actually worth the trouble
I wouldn’t reach for a state machine on every CRUD endpoint. If your lifecycle is created → updated → deleted, normal application code handles that fine, and adding formal states just gives you more scaffolding to maintain for no real benefit.
It starts paying off once the workflow itself becomes the hard part — payments, orders, deployments, background jobs, auth flows, document processing, LLM generations, approval chains. These have real states and real rules about what can follow what. A payment starts simple:
pending
↓
processing
├────→ completed
└────→ failed
Then you add refunds:
completed
↓
refund_pending
↓
refunded
Then partial refunds. Then refund failures. Then retries on top of that. Then a webhook fires twice because the provider’s infrastructure had a bad day. At some point “just update the status” stops being an honest description of what your code is doing, and the lifecycle deserves to be modeled as its own thing rather than patched in as an afterthought.
The diagram isn’t really the point
State machines get presented as boxes and arrows a lot, and that’s probably the least useful part of the idea. The questions that actually matter are the backend questions underneath it: what states can exist, what events can occur, which transitions are valid, what happens when a transition fails partway through, who’s allowed to perform it, how do you make it atomic, and what happens when two of them fire at the same time. The state machine is just the thing that forces you to write down answers to those instead of discovering them in production.
It makes testing less vague, too
Once transitions are defined, you stop writing tests like “test that generation works” and start testing the lifecycle directly:
queued → generating ✓
generating → streaming ✓
streaming → completed ✓
generating → failed ✓
failed → queued ✓
completed → generating ✗
cancelled → streaming ✗
completed → cancelled ✗
Every valid transition gets a test. Every transition that should be rejected gets one too. If the machine is small enough, you can actually reason about the whole lifecycle instead of crossing your fingers and hoping your integration tests happened to cover the weird case.
Why I care about this right now
I’m building an LLM chat backend at the moment, and this is exactly where it stopped being theoretical for me. “prompt → LLM → response” is fine for a demo, but a real service has a whole lifecycle wrapped around that one call. The model can be slow. The request can get retried. The client can disconnect mid-stream. The user can cancel. The worker process can just die. The response can be half-streamed when something goes wrong. The database write can succeed while the client never sees it. Once those things start mattering, the request isn’t a function call anymore — it’s a process moving through states, and I’d rather define that explicitly than let it emerge from scattered conditionals and hope.
Don’t reach for this because it sounds impressive
Last thing, and it matters: a state machine isn’t automatically good architecture. If the workflow is simple, adding one just gives you more abstraction to maintain for nothing. The question isn’t “can I model this as a state machine” — almost anything can be. The actual question is whether the lifecycle of this thing has gotten important enough that its rules need to be written down somewhere explicit, instead of living in someone’s head or scattered across a dozen conditionals.
If yes, model it. If not, don’t. Most backends already have a state machine hiding in them somewhere. The only real question is whether anyone’s bothered to write it down — or whether it’s still just a status column and a prayer.