telephones and pianos

When I started building p2piano, a peer-to-peer platform for playing piano together over the internet, state management challenges started to come up. Network timing, peer events, and real-time audio synchronization all needed to stay in sync with each other in a predictable and understandable manner.

Around this time I started reading about erlang, which ultimately led me to build ensemble, an actor-based state management framework inspired by erlang.

erlang's model

Erlang was built at Ericsson in the 1980s to run telephone systems that couldn’t go down. Its designers faced a fundamental constraint: telephone switches run on multiple processors and must stay running even when individual components fail. Their solution was to give each process its own isolated memory, with no shared state between processes.

In Erlang, everything runs in a process, a lightweight, isolated unit of computation. Processes don’t share memory with each other. The only way they communicate is by sending messages, which arrive in each process’s mailbox and are handled one at a time. If a process crashes, it doesn’t take anything else down with it.

Think of it like a large organization where each department keeps its own internal records that nobody outside can touch. Departments can only interact by sending formal memos. A fire in one department’s filing room doesn’t destroy any other department’s records.

isolation enforces ownership

In frontend state management, a subtle problem that can often arise is that nobody really owns anything. With a shared root store, your entire application’s state lives in one space that anyone can read or dispatch against. There’s nothing stopping a component in one feature from depending on implementation details of a different feature, or a new engineer from bypassing validation logic by dispatching directly.

In Erlang, only the process itself can read or write its own memory. The only way to observe a process’s state is to send it a message and wait for a reply.

In ensemble, actors enforce this same constraint. Each actor’s state starts fresh with no shared references. External code interacts through an ActorClient, which maintains a local cache updated via state change messages. You never touch an actor’s state directly.

The side effect is that business logic has a natural home. An actor that owns a piece of state can enforce invariants on it without worrying about other code paths that might bypass those rules. Your actors end up being modeled in a dependency graph we call the ActorSystem.

mailboxes make concurrency tractable

Each Erlang process has a mailbox. Messages pile up in FIFO order and are processed one at a time. The process handles one message completely before looking at the next.

This eliminates an entire class of race conditions. A process’s state only ever transitions in response to a message, so you can reason about state changes without worrying about interleaving.

Ensemble implements this directly. Worker-thread actors have a Mailbox that queues incoming action calls and processes them sequentially. One action runs to completion before the next begins. The mailbox also isolates errors: if one action throws, that message fails but the next one in the queue still gets processed.

This maps well to a real problem in p2piano, where multiple async events arrive in quick succession: peer note events, timing corrections, connection state changes. A mailbox turns this into a simple queue with a predictable ordering guarantee.

location transparency

In Erlang, you send messages to a process the same way regardless of whether it’s running on the same machine or a remote node in a cluster. The caller doesn’t need to know whether the process is local or remote, only how to address it.

The frontend equivalent is Web Workers. Workers run in separate execution contexts with no shared memory, just like Erlang nodes. Moving computation to a worker should ideally look the same from the calling code’s perspective.

In ensemble, ActorClient provides this. Whether an actor is running on the main thread or in a Web Worker, you use the same API: read client.state, call client.actions.doSomething(), subscribe with client.on(...). The routing infrastructure handles message delivery across thread boundaries transparently. You can reassign an actor to a worker thread via configuration and the rest of your code doesn’t change.

The upshot is that you don’t have to design around workers upfront. You can add them later once you know you need them.

state across machines

Location transparency gets more interesting when you push it further. Web Workers and iframes are isolated execution contexts within a single browser, but p2piano is a multi-user application. The harder synchronization challenge is state shared across machines, over the network, with participants who can all make changes at the same time.

CRDTs (Conflict-free Replicated Data Types) handle this. A CRDT can be independently modified by multiple participants and merged without conflicts. Two users can make changes simultaneously and the system reconciles them deterministically, no central coordinator needed.

Ensemble’s collaboration package integrates Automerge CRDTs directly into the actor model. The CRDT document is the actor’s state, not a wrapper around it. You call setState() the same way you would in any other actor, and the framework handles propagating changes to connected peers via WebRTC, with a WebSocket fallback. The rest of the application sees state changes on an actor, same as any other.

For p2piano, which already uses WebRTC to send note events between peers, this fit naturally. A room’s shared state lives in a CollaborationActor, peers sync automatically as they connect, and conflicts resolve without coordination. And because each transport concern is its own actor, they can be tested and swapped out independently.

an experiment

Ensemble is an experiment. I built it to solve real problems in p2piano and to explore whether these patterns could work at the scale of a browser application. The core concepts are solid and the framework is in active use, but the APIs are still evolving and it’s in no way production-ready yet.

If you’re curious about ensemble, the source is at github.com/d-buckner/ensemble.