Introducing Ekman: a runtime you embed, not a platform you operate
Ekman is a small TypeScript library for the part of a backend service that everyone ends up writing by hand and nobody enjoys maintaining: a state machine. Define a it once and the runtime owns everything around it. It all runs inside the service that needs it.
Where the complexity goes
Every backend has things with a lifecycle. An order moves from pending to paid to shipped. A deployment moves from queued to deploying to healthy, or to rolled back. A device connects, drifts, and reconnects. None of this is hard to describe. What is hard is running it: knowing what state each instance is in right now, making sure two triggers do not reach the same one at once, retrying the work that failed, and answering "what happened to abc123" a week later.
The usual pattern is that each lifecycle becomes its own service. The first one is a switch statement inside an existing handler. The second one needs retries, so it gets a queue. The third needs history, so it gets a table and a worker. By the fifth, the team is operating a small fleet of services that all do the same job with slightly different bugs, and nobody can say what state anything is in without querying several of them.
The state machine was never the problem. The problem is that everything around it, ordering, persistence, retries, timeouts, history, and querying, gets rebuilt for every one.
There are two established ways out. Orchestration platforms such as Temporal, Restate, and Orleans give you durable, addressable instances with all of that handled, but you deploy and operate their runtime to get it. State machine libraries such as XState give you a clean way to model transitions and leave identity, ordering, persistence, and querying to you. One is a platform to run. The other is a library that stops where the hard part starts.
I built Ekman so there would be one standard way to define a state machine, one runtime that handles everything around it, and no separate service to operate. Because every entity is defined the same way, every lifecycle in a service gets the same guarantees and the same tools. The order workflow and the deployment workflow are queried the same way, retried the same way, and audited the same way. That is the part that removes services. It is not that any one of them was hard to write. It is that they no longer need to exist separately.
What it looks like
import { defineEntity, Ekman, stay, transitionTo } from 'ekman'
const orders = defineEntity('orders', {
initial: 'pending',
values: { total: 0, receipt: '' },
states: {
// One handler runs per key at a time, so read, await, write needs no lock.
pending: async (order, trigger) => {
if (trigger.type !== 'pay') return stay(order.values)
const amount = trigger.amount as number
const receipt = await chargeCard(amount)
return transitionTo('paid', { total: order.values.total + amount, receipt })
},
paid: (order) => stay(order.values),
},
})
const ekman = new Ekman({ entities: [orders] })
const committed = await ekman.entities.orders.send('a-1', { type: 'pay', amount: 4200 })
console.log(committed.state, committed.values)
An entity is a map from states to handlers. A handler receives the instance and a trigger and returns one of three results: transitionTo, stay, or fail. Nothing else commits. Every instance is addressed by a key like orders:a-1. The key is human-readable since deployments:abc123 is what shows up in a log line and a support ticket, and hashing it would trade that for nothing.
The lineage is the virtual actor model: an addressable instance with identity, memory, and serialized message processing. Ekman keeps that and drops the cluster. What follows is the set of decisions that make the rest of it hold.
Ownership instead of locks
One key owns one instance's state, values, inbox ordering, resident memory, persistence, sequence numbering, and history. Nothing is shared across keys, which is why nothing has to be locked across them. One handler runs per key at a time, so a handler can read, await something slow, and write back without checking whether anyone else is mid-flight. Handlers for different keys overlap freely.
Behind each key is a FIFO inbox bounded in triggers. The default overflow policy is reject, because an unbounded queue converts overload into silent latency and memory growth, and the sender should find out. drop-newest and drop-oldest exist for load shedding. Capacity bounds the backlog, not the backlog plus the running handler, so capacity: 0 means "one at a time, no queue" rather than "refuse everything", which is the difference between an edge case and a trap.
Fencing over cancellation
A running JavaScript function cannot be stopped. Most systems pretend otherwise. Ekman does two things on a timeout: it invalidates the attempt's commit token, then aborts the handler's AbortSignal, then rejects the caller. The signal is a courtesy to handlers that watch it. The fence is what holds for handlers that do not. When a zombie handler finishes late and tries to commit, it is refused at the one method every commit goes through, and the refusal shows up in telemetry as commit.fenced rather than being merely unlikely.
There is one window the fence cannot close. Writing to a store takes time, and a timeout can fire in the middle of it. Once an event has been handed to the store, the token is sealed and can no longer be fenced. The alternative, re-checking after the write and refusing, would leave the store holding an event the runtime declined to apply, and replay would then reconstruct a state the live runtime never had. Between "a sender was told it timed out and the work landed anyway" and "the store and memory permanently disagree", the first is recoverable and observable and the second is not. So the race is recorded as commit.raced instead of hidden.
Persist, then apply
The commit path is build the event, check the fence and seal the token, append to the store with the expected sequence, then apply state, values, sequence, and event in one synchronous block. Persist first, because an event that reached the store is durable and memory has to agree with it. Applying first would leave a window where a crash loses a change the runtime already reported as committed. The apply step stays synchronous so no observer reading through the runtime ever sees a half-applied commit.
Stores are layered, [memoryStore(), fileStore(dir)], and the commit authority is the last durable layer, because a stack reads fastest-to-slowest and the truth lives at the slow end. Caches are written after the authority and are not awaited. A cache that fails reports store.cacheFailed and the commit stands, because by then it is durable, and treating a stale cache as a failed commit would turn a performance problem into a correctness one. Every store declares what it can do, and the runtime refuses a configuration it cannot honor. An ephemeral store in front of a durable one is refused outright, because it would make a durable store a cache of an ephemeral one and a restart would silently lose everything while a perfectly good store sat right there.
Omit store entirely and nothing survives the process. That is a documented mode, not a degraded one. The one thing a state runtime must never do is claim durability it does not have.
Memory as a budget
The default failure mode of homegrown state is an unbounded map of resident instances. Ekman accounts every resident instance in bytes at commit, measured as the UTF-8 length of the key, the state name, and the serialized values. That is approximate as a measure of heap and exact as a measure of itself, which is the property a testable budget needs.
Eviction acts when a key goes idle, not at the commit that blew the budget. This came out of maxBytes: 0 doing nothing at all: a commit happens inside the key's turn, when the key is by definition busy, and a busy instance is never evicted, so an instance could never evict itself however cold and however far over budget the runtime was. The consequence, which the memory demo asserts rather than pretending it is zero, is that the resident set can exceed the budget by at most the one instance currently committing. Evicted instances reload transparently on the next trigger, and an eviction policy of none accounts and reports without acting, so you can watch the number before enforcing it.
This is what makes auditing without memory explosion work. History and audit events go to the store and to audit sinks out of band, never gating a commit. The budget decides what stays resident. You get a full record of what happened to every instance without holding every instance in memory, and without a service whose only job is to remember.
One stream, honest answers
Every event for a key lives in one ordered stream: transitions, rejected triggers, constraint violations, restores. A sequence number identifies a committed state, not a stream position. A commit advances it and everything else carries the sequence of the commit it followed. That small decision means "every rejection in the last hour" is a query against the stream an operator already reads instead of a side channel nobody wires up, and replay reads transitions alone, so the extra entries cannot affect reconstruction.
Queries ask questions like "every deployment stuck in deploying for more than five minutes". A result is { instances, complete, reasons }, and a memory-only runtime is never complete, even when it happens to hold everything, because it cannot know that it does. A store that cannot evaluate a filter says so, the runtime applies it afterwards, and the answer is flagged as partial. A boolean alone would satisfy the letter of that and be useless. An operator acting on "nothing is stuck" needs that to have meant it.
Constraints work the same way. Transition graphs, guards, invariants, and time-in-state bounds are opt-in, and each can run in warn mode. warn lets a team discover their real transition graph from production traffic before enforcing it, and violations land in the same stream as transitions. Strictness is earned, not imposed.
Measured, not assumed
By the project's own benchmarks a single commit costs a few microseconds, and per-key ordering is not a throughput ceiling: one key with a one-millisecond handler does about 800 commits a second, and fifty such keys do about 28,000, because handlers overlap across keys while each key stays strictly in order.
The constraint check went from 215 nanoseconds to under 20 by removing a closure allocation, allocating the result list only on a violation, and carrying the interned state id on the resident record. A bitset for the transition graph was measured and rejected: it only pays with both state ids in hand, and only the source id ever is, because the target is a string a handler just produced. An array of sets indexed by the source id measured faster and needs no bit twiddling. One change that looked like a win moved every figure by one to three percent against spreads of two to twelve, so it is recorded as "no change". The baseline file was left untouched so every number is reproducible against what is committed.
Spec before ports
Underneath the package is a language-agnostic spec and a conformance suite of 68 scenarios that any implementation runs through its own public API. The suite was built alongside the TypeScript implementation, not after it, so "conforming" is a checkable claim rather than a hope. A level is claimed when everything it requires is implemented, not when its scenarios pass, because scenarios only cover what somebody wrote a scenario for. This package claims Core and Durable. Coordinated is not claimed. The runtime half, detecting a concurrent writer and recovering from it, is built. The store half needs a conditional append that is atomic across processes, and a file store cannot promise that: a lock directory holds on a local filesystem and not on NFS, and the store cannot tell which it is on. A capability that is true on some filesystems is a coin flip with a docstring, so the file store declares multiWriter: false and the runtime refuses multi-writer coordination on top of it at startup. There are also 13 runnable demos that assert rather than print, so a broken claim fails the run instead of scrolling past.
The spec exists because the case for embedding a runtime instead of operating a platform is strongest exactly where a platform is not an option: a device, an agent on a host, a service in a language with no orchestration ecosystem. Ports conform to behavior, not to a shared core over FFI, so each one stays idiomatic. Go is next, then Rust and C++, so a state machine defined for a backend service can be embedded in a low-level system with the same semantics. Extensions come after that, so stores, trigger sources, and integrations can be added on top of the core rather than into it.
Try it
npm install ekman
If you have a service full of hand-rolled lifecycle code, or a fleet of small services that each exist to run one, I would like to hear how it goes. The code, docs, spec, and scenarios are on GitHub, and the package is on npm.