Intro
There are many instances that come up when building web applications where you want some kind of real-time shared state between multiple clients — at minimum so that users are aware of what the others are doing, and often going as far as letting them modify the same thing at the same time, with instant feedback.
This article explains the approach that I've settled on after trying a few different ones. I've used it in two of my projects — Squad Layer Manager (SLM) and livechess.xyz. First, let me explain the model, and then we'll compare it to some of those other approaches I considered.
Server-authoritative / client-optimistic, with shared update logic between client and server
Generally speaking, in web applications, the way that clients perform mutations to server-side state is to perform an RPC on the server, and then display a loading state while we wait for the new state to be returned to the client. This is fine for most purposes, but if we want to minimize felt latency like we would want to for shared-editing or otherwise real-time scenarios, we need to be a bit more clever. The likely-familiar approach to this problem, at least in the web world, is optimistic updates, which is where we update the client's view optimistically before waiting for the server to return with the authoritative result. This is sort of the approach we'll be taking, but we're also aiming to side-step the problems that naive optimistic updates create with the N+1 duplicated mutation codepaths between the client and the server for each mutation, while at the same time massively reducing the amount of data that needs to go over the wire.
The idea is to give all clients a replica of the state we intend to share, as well as the associated update logic so that any "node" has the machinery to update the shared state deterministically. Then, we can encode all potential mutations to this state as serializable operations. The update logic is then a reducer — a pure function that takes the current state and an operation and returns the next state, the same shape you'd hand to Array.reduce. Nothing else is allowed to touch the shared state; every change to it is an operation run through the reducer.
Clients listen for incoming operations from the server, and when any client wishes to modify the shared state, it pushes an operation, first to a new local copy of the state, as well as to a list of pending operations, which it is waiting for the server to integrate. Once it sees its pending operations in the incoming stream, it can then reconcile that state with its local optimistic copy, which could result in a rollback if the operation histories diverged, or just a simple acknowledgement that the client's local copy of the state is now the authoritative state.
This approach has a number of nice qualities, which we'll look at when we compare it to other options, but the clearest one compared to more naive optimistic updates is the simplicity of the resulting code as long as you have a shared language between client and server. We can implement a fairly small library that deals with setting up the state replication machinery, and then our actual application code is generally very straightforward. Another is that we naturally will send just enough data to perform the update over the wire instead of an amended copy of the state, which is an optimization that will add up quickly in multi-client scenarios.
This is an approach I stumbled upon through trial and error after trying other more advanced techniques like CRDTs, but I'm far from the first to discover it. Replicache is nearly the same design, and Figma's multiplayer core is also server-authoritative, though it resolves conflicts per-property rather than by replaying operations. It also shares the same core philosophy as rollback "netcode" in the family of GGPO — the network replication layer of a game engine — though it's able to be much simpler because it doesn't need the per-frame granularity that a fighting game like Tekken or Super Smash Bros. does.
The rest of this section splits the model in two: first the part that belongs to your application — the state, the operations that change it, and the reducer that applies them — and then the sync protocol around it, which is generic, and which you write once and reuse.
The app's logic
To make this concrete, let's use the example of a hypothetical web CMS with shared editing capabilities. It has tags, for organizing hosted articles, and posts which reference those tags. Since nothing here needs to be a special replicated data structure, the state is just a plain value:
type Tag = { label: string; description: string }
type State = {
tags: Record<string, Tag> // tagId -> tag
postTags: Record<string, string[]> // postId -> [tagId]
}Notice that the normalization creates an invariant. The moment one piece of state refers to another one by id, something has to guarantee that the reference actually resolves — and it's obvious enough that you'd probably never write it down: every tag id in postTags refers to a tag that exists in tags. This will become relevant later.
In order to update this state, we define a set of well-known operations, and the reducer that fully describes how each one applies. Again, the state, the operations, and the reducer are shared code — the same module runs on the client and the server:
type Op = { opId: string } & (
| { code: 'create-tag'; tagId: string; label: string; description: string }
| { code: 'delete-tag'; tagId: string }
| { code: 'tag-post'; postId: string; tagId: string }
)
const reducer = (state: State, op: Op): State => {
switch (op.code) {
case 'create-tag': {
if (state.tags[op.tagId]) return state
const tag = { label: op.label, description: op.description }
return { ...state, tags: { ...state.tags, [op.tagId]: tag } }
}
case 'delete-tag': {
if (!state.tags[op.tagId]) return state
const { [op.tagId]: _removed, ...tags } = state.tags
// the invariant, enforced in the one place it can be
const postTags = Object.fromEntries(
Object.entries(state.postTags).map(([postId, tagIds]) =>
[postId, tagIds.filter((id) => id !== op.tagId)],
),
)
return { ...state, tags, postTags }
}
case 'tag-post': {
// ...and the reference is checked before it can ever be created
if (!state.tags[op.tagId]) return state
const tagIds = state.postTags[op.postId] ?? []
if (tagIds.includes(op.tagId)) return state
return { ...state, postTags: { ...state.postTags, [op.postId]: [...tagIds, op.tagId] } }
}
}
}It's worth noting that this model doesn't prevent us from having server-only checks — permissions, or invariants that need data outside the replicated state, like uniqueness against a database table. Those run as guards on the server before the op ever enters the reducer: an op that fails is refused rather than applied, and the refusal flows back to the originating client, which drops its optimistic copy and can surface the error (see the rejection machinery in the additions section below). For slow or expensive checks, the async side-effect pattern shown later is the better fit — the initiating op parks the state as "pending", a side effect performs the validation, and a response op — again initiated by the server — applies or cancels the change.
The sync protocol
So that's the actual application logic. Let's briefly look at what the replication protocol looks like:
- each client (and the server) holds its own full copy of the state. A client actually holds two — the last state the server confirmed, and that same state with its own pending ops applied on top:
type ClientSession = { syncedState: State localState: State // syncedState + pendingOps -- this is what the UI renders pendingOps: Op[] // sent, not yet confirmed by the server } // and what the server sends to a watching client type ClientUpdate = | { code: 'init'; state: State } // on connect | { code: 'op'; op: Op } // this op was accepted - when a client wants to update the state, it creates an operation, applies it to its local state immediately, and queues it to send to the server.
- the server processes clients' operations in the order it receives them, running the same reducer. It may also push operations of its own. Any operation it processes is then broadcast back to all clients.
- when a client receives an operation from the server, it advances its synced state, drops the op from its pending queue if it was its own, and rebuilds its local state by replaying whatever is still in flight. That replay is the rollback; there is nothing else to it.
type BaseOp = { opId: string }
type Reducer<O extends BaseOp, S> = (state: S, op: O) => S
// replaying a list of ops is just a fold over the reducer
function replay<O extends BaseOp, S>(
state: S, ops: O[], reducer: Reducer<O, S>,
): S {
return ops.reduce(reducer, state)
}
// --- the server: it just runs the reducer, then broadcasts what it accepted ---
namespace Server {
export function applyOp<O extends BaseOp, S>(
state: S, op: O, reducer: Reducer<O, S>,
): S {
return reducer(state, op) // ...and send the op to every connected client
}
}
// --- the client: two states, and a queue of ops in flight ---
namespace Client {
export type Session<O extends BaseOp, S> = {
syncedState: S // the last state the server confirmed
localState: S // syncedState + pendingOps -- what the UI renders
pendingOps: O[] // sent, not yet confirmed by the server
}
// the user did something. apply it immediately, queue it to send.
export function processOutgoingOp<O extends BaseOp, S>(
session: Session<O, S>, op: O, reducer: Reducer<O, S>,
): Session<O, S> {
const localState = reducer(session.localState, op)
return { ...session, localState, pendingOps: [...session.pendingOps, op] }
}
// the server accepted an op -- possibly our own. advance the synced state,
// drop it from our queue if it was ours, and rebuild the local state by
// replaying whatever is still in flight. that replay is the rollback;
// there is nothing else to it.
export function processIncomingOp<O extends BaseOp, S>(
session: Session<O, S>, op: O, reducer: Reducer<O, S>,
): Session<O, S> {
const syncedState = reducer(session.syncedState, op)
const pendingOps = session.pendingOps.filter((p) => p.opId !== op.opId)
const localState = replay(syncedState, pendingOps, reducer)
return { syncedState, localState, pendingOps }
}
}A concurrent edit, reconciled
So let's run a conflicting pair of edits through this, against the invariant from earlier. Clients A and B start synced, and then edit without hearing from each other:
// B retires the tag. one op -- the cleanup lives in the reducer,
// so it isn't something a caller can forget or do half of.
B: dispatch({ opId: 'b1', code: 'delete-tag', tagId: 'archived' })
// A, who hasn't heard about that yet, tags a post with it
A: dispatch({ opId: 'a1', code: 'tag-post', postId: 'post-2', tagId: 'archived' })
// optimistically, on each client:
A: tags: { typescript, crdt, archived } post-2: [crdt, archived]
B: tags: { typescript, crdt } post-2: [crdt]Both ops reach the server, possibly in either order. Say B's delete arrives first: when the server then replays A's tag-post, the guard in the reducer sees that the tag is gone and drops it. A receives the delete, rolls its local state back to synced, replays its still-pending tag-post against it, and gets the same answer the server did. The tag assignment that A tried to add flickers on A's screen and disappears. It's worth acknowledging that this might not be the ideal user experience in all cases — for impactful operations, we should probably represent that the current view is not final yet. This could be done by checking for differences between the client's known server state and the local state, or by having a separate operation that the server is only allowed to push which marks the given state as now definitive.
Toy example
Below is a toy implementation of this model that you can play around with:
Side effects
One piece that's still missing is that we will probably want to enumerate and handle some changes in the definitive state on both the client and the server. The best way to do this in my experience is to have the reducer also output a list of side effects, which the diverging calling code on the client and the server can handle imperatively, each in its own way.
type SideEffect =
| { code: 'tag-created'; tagId: string }
| { code: 'tag-deleted'; tagId: string }
type Reducer = (state: State, op: Op) => [State, SideEffect[]]
// ...and in the reducer, alongside the state change:
case 'delete-tag': {
if (!state.tags[op.tagId]) return [state, []] // no delete, no side effect
const { [op.tagId]: _removed, ...tags } = state.tags
const postTags = /* ...stripped of the tag, as before */
return [{ ...state, tags, postTags }, [{ code: 'tag-deleted', tagId: op.tagId }]]
}The caller is then free to do something completely different with them on each side:
// on the server
const [state, sideEffects] = reducer(prevState, op)
for (const effect of sideEffects) {
if (effect.code === 'tag-deleted') await db.dropTagIndex(effect.tagId)
}
// on the client
for (const effect of sideEffects) {
if (effect.code === 'tag-deleted') toast(`Removed "${effect.tagId}"`)
}The reason we would do this instead of just having additional handling code for incoming operations is that we want our reducer logic to, as much as possible, determine what should happen as the result of the incoming operations. For example, if an operation is deemed to be "invalid" by the reducer, then we may want a different side effect than in the successful case. We may even want, in some cases, to emit multiple side effects for a given operation. As such, we want our handling code to have a way to hook into the reducer's logic instead of trying to duplicate or work around it.
Notably, we would generally want to only handle produced side effects for the definitive version of the state, not the optimistic state, as we generally don't want to implement custom rollback logic for things outside of our replicated state. In terms of the library module from earlier, that means the reducer's return type becomes the tuple, processIncomingOp returns the side effects alongside the updated session, and processOutgoingOp — the optimistic path — simply discards them. For your implementation, you may choose to also allow handling side effects in the optimistic update path, but for simplicity we've omitted it here.
An example of how we might use side effects is to handle asynchronous server-side operations. The general pattern is something like this:
- upon receiving an initiating operation, emit a side effect which is handled by the server.
- the side-effect handling code completes the operation, and pushes a "response" operation.
// spreads for a one-entry update get noisy; a small helper keeps the cases readable
const withTag = (state: State, tagId: string, patch: Partial<Tag>): State => ({
...state,
tags: { ...state.tags, [tagId]: { ...state.tags[tagId], ...patch } },
})
// 1. the initiating op parks the tag in a pending state and asks for the work
case 'request-tag-icon': {
const tag = state.tags[op.tagId]
// tag deleted concurrently, or a generation is already in flight
if (!tag || tag.icon.status === 'generating') return [state, []]
const next = withTag(state, op.tagId, { icon: { status: 'generating' } })
return [next, [{ code: 'generate-icon', tagId: op.tagId }]]
}
// 2. the response op, pushed by the server once the work is done
case 'set-tag-icon':
if (!state.tags[op.tagId]) return [state, []] // deleted while generating
return [withTag(state, op.tagId, { icon: { status: 'ready', url: op.url } }), []]...and only the server bothers to handle that side effect:
for (const effect of sideEffects) {
if (effect.code !== 'generate-icon') continue
void generateIcon(effect.tagId).then((url) =>
dispatch({ opId: newOpId(), code: 'set-tag-icon', tagId: effect.tagId, url }),
)
}Every client sees the tag flip to generating the moment anyone asks for it, and sees the finished icon when it lands, without a single request-scoped loading flag anywhere in the UI. Note the guard at the top of request-tag-icon: if two clients ask concurrently, the server sequences the two ops, the second one sees the tag already generating, and exactly one generation is kicked off — deduplicating the work takes one ordinary if check against the replicated state. The progress of the async operation is just part of the replicated state, so it is shared for free:
type Tag = {
label: string
description: string
icon:
| { status: 'none' }
| { status: 'generating' }
| { status: 'ready'; url: string }
| { status: 'failed'; error: string }
}Other suggested additions
There are still a few pieces missing here that in practice you may want. You probably want some form of operation rejection logic, so that operations that are effectively no-ops don't have to be broadcast at all, and won't lead to confusing behavior like an operation the client considers "rejected" being accepted from the point of view of the server. You will also probably want a few optimizations like not sending full operations back to the client that authored them — ops are deterministic, so the server can acknowledge them by id and let that client replay its own pending copies — as well as the ability for clients to start streaming operations before even getting their initial states.
Since the whole scheme rests on the reducer being deterministic, it's also worth building in some form of divergence detection: any nondeterminism that sneaks in — a clock read, an unstable sort, version skew between a freshly deployed server and a stale browser tab — will silently fork a client's replica from the server's. A cheap signal is a failed or rejected replay of an op the server already accepted (the server only broadcasts what it accepted, so a client that can't apply one has diverged); a stronger one is a periodic state checksum piggybacked on broadcast updates. Conveniently, the recovery already exists in the protocol: the server re-sends init and the client rebuilds from the snapshot. That same re-init doubles as a general escape hatch for any client whose state has gone bad for reasons you never anticipated.
However, even when you add all of this, the core of the functionality is shockingly simple, and can be implemented in just a few hundred lines of code.
Feel free to steal my current implementation for Squad Layer Manager here.
What it costs
The biggest limitation for many will be that the same update code runs on both the client and the server, meaning that in practice, at least for web development, your server probably needs to be running JavaScript. Maybe you could embed WebAssembly on the client with whatever language you want to write the reducer and types in, but at least for now, this feels hacky. If you're not keen on JavaScript or TypeScript, this may be a problem for you.
As a side note, code-sharing between the client and server is often underestimated as an advantage to using a runtime like nodejs instead of a dedicated backend language. This state replication system is a particularly clear chrystilization of the advantages, but just being able to move a particular piece of logic between the client and the server on a whim is incredibly powerful and can lead to many impactful optimizations and simplifications. I also would like to live in a world where I could write frontend code in a more performant language without much downside, but we're not quite there yet, and so nodejs and other JavaScript runtimes still earn their place.
Another assumption worth making explicit: this model works best when the replicated state fits comfortably in memory — on the server and on every client. Each client holds a full replica (two, counting the optimistic copy), the initial snapshot travels over the wire on every connect, and rollbacks are recomputed by replaying pending ops against full copies of the state. For the shared-editing scenarios this post mentions — a queue, user presence, a document's worth of structured state — this is comfortably the case, and cranking through even hundreds of operations of pure synchronous reducer code is imperceptible. But if your data is closer to "dataset" than "document", don't replicate all of it: scope a store to the slice actually being collaborated on, and serve the rest through ordinary request/response.
A subtler tradeoff shows up when there's state that conceptually wants to live inside the same structure as the replicated store, but shouldn't — either because other users have no business seeing it, or because it's too much data to push to clients that don't need it. The side-effect system handles this case reasonably well, and I would definitely avoid trying to diverge the state or the reducer functions between the client and the server, as there's too much room for bugs to sneak in once you start going down that road.
Case study: shared editing in SLM
I developed this approach building Squad Layer Manager, and later ported livechess.xyz to use it from its initially unnecessarily complex approach to the same problem, which is probably worth its own blog post. Focusing on SLM briefly, here is a pretty cool application of it (in my opinion anyway).
The "Layer Queue" (the function of which is not important here) is a shared resource that often wants to be edited concurrently, but for which it's important that all edits are applied coherently, and all users are able to know what the others are working on:
Here there actually are two separate replicated stores working together, each with its own set of operations. One tracks user presence (which applies beyond edits to the layer queue), holding state like which users are actively editing, and if so what they're working on. There's a separate store for the layer queue's state itself. (The fact that these stores are separate is more of an architectural quirk of SLM, and they could just as easily be combined, though in practice it's totally workable to have many different siloed stores for different activities or subject matter. You can use the side-effect system to keep the necessary interdependent state synced up on the server.)
One interesting pattern that this kind of system allows for user presence is to tie the actual navigation state directly to the replicated state.
In the above recording, whenever the user switches between the Queue and Teams tabs, clicks "Add Layers", or opens any of the other relevant dialogs, the dialogs' state as well as the state of the tabs is controlled directly by the locally replicated user presence state for the user.
This lets us do things like automatically close a dialog with an accompanying toast if whatever the user was working on is no longer valid because of some change in the server state. In the case of SLM, this is done as a part of a more holistic approach to hoisting in-page navigation state outside of the React component lifecycle, in a manner that also gives us access to the "loader pattern", which enables intelligent preloading based on user interaction similar to what you'd get in a library like TanStack Router.
What about CRDTs?
Besides conventional optimistic updates, the approach this model most directly competes with is using an existing CRDT implementation. Conflict-free replicated data types (CRDTs) are data structures for which any given set of updates will eventually resolve to the same end-state, no matter what order each replica sees them in. This is a very useful property, because it means replicas converge without any coordination at all — no server ordering, no consensus protocol — so long as all clients follow the rules of the CRDT. That's what makes direct peer-to-peer editing possible in principle.
A great example is YJS, an incredibly capable JavaScript library (with an available port in Go) with a fast CRDT implementation. From the outside it's simple: you make a document, pull shared types out of it — Y.Map, Y.Array, Y.Text — and edit them like the ordinary collections they resemble. Every edit produces an update, and feeding those updates into another document converges it on the same state, no matter what order they arrive in.
CRDTs are a perfectly viable way to build something like the initial example we walked through above. At first they seem like a magic bullet for real-time state sharing, with the added benefit of being well-suited to local-first applications if you like that sort of thing. The issue with them arises in how you enforce correctness invariants, by which I mean the rules that define what valid states exist for your application.
CRDTs and invariants
A key fact to understand about CRDTs is the following:
The only "correctness invariants" that a CRDT can ensure are ones which are inherent to its data structure or are otherwise handled in the CRDT's merging algorithm.
This generally does not matter for editing text, as YJS knows how to merge a set of edits into a single still-parsable text document. However, things get trickier if you try to model more structured or relational data, because the invariants you care about tend to live between two pieces of state rather than inside either one of them — like our rule that every tag id in postTags refers to a tag that exists.
To see the problem, let's model the CMS state from before with YJS's generic shared types, the natural way:
// tagId -> { label, description }
const tags = doc.getMap<Y.Map<string>>('tags')
// postId -> [tagId]
const postTags = doc.getMap<Y.Array<string>>('postTags')Then replay the concurrent edit from earlier: B retires "archived" and, in a single YJS transaction, removes it from every post that has it; A, who hasn't received that update yet, tags post-2 with it. The transaction doesn't help — it only batches B's changes into one update message; it can't be atomic with respect to A, because there is no moment at which the two clients agree on an ordering in the first place. And the merge sees nothing to resolve: B's deletion and A's insertion live in different collections, and YJS only ever positions an edit relative to its neighbors within the same collection. Both clients converge, on a state that breaks the invariant:
// A and B, identical:
tags: { crdt, typescript }
postTags: { post-1: [typescript], post-2: [crdt, archived] }
// ^^^^^^^^
// no such tag anymoreConvergence and correctness are two different things, and a CRDT only ever promises you the first one. So if the merge can't enforce the invariant, where does it go? The practical answer — and roughly what an experienced CRDT user would reach for here — is to encode it into the shape of the state and the way it's read, so that every state the merge can produce is harmless. Two changes:
// 1. tags are never deleted -- they're tombstoned. a tag id, once created,
// always resolves, so there is no such thing as a dangling reference
const retireTag = (tagId: string) => tags.get(tagId)!.set('retired', 'true')
// 2. nobody reads postTags directly. a post's tags are *defined* as the ids
// that resolve to a live tag -- the invariant is now a property of reads
const postTagIds = (postId: string): string[] =>
(postTags.get(postId)?.toArray() ?? []).filter((id) => {
const tag = tags.get(id)
return tag !== undefined && tag.get('retired') !== 'true'
})Re-run the scenario and it comes out right. A's concurrent edit still lands in the underlying data — nothing stops it — but every reader sees post-2 without "archived", because the tag is retired. The invariant holds, not because invalid states can't be written, but because the read layer defines them out of existence. It even comes with a coherent merge semantic for free: un-retire the tag and the old associations reappear.
This works, and it's genuinely not much code. But it's worth comparing what we just did against writing the guard clause in the reducer:
- the invariant became a data-modeling exercise. We never wrote the rule down; we redesigned the state (tombstones instead of deletes) and the read path so that the rule is implied. For every new invariant the question isn't "where do I put the check?" but "what shape of data makes every merge outcome acceptable?" — and it has to be answered against every possible concurrent interleaving, not just against the current state and some finite set of operations.
- it's enforced at every read site instead of one write site. Any code that reads
postTagsdirectly — now, or a year from now — silently reintroduces the bug. The reducer's guard sits on the one code path every replica already runs. - I would claim that we created a number of problems for ourselves here. While this is a defensible approach to some degree, you're accepting an additional source of complexity here that will accumulate with every new behavior that's added to your code. Basically, it's far easier to write the code when we don't have to worry about every possible invalid state that can be introduced during merges.
- Another important point is that these tricks worked because of this invariant's particular shape. A dangling reference can be filtered away at read time. "Two posts can't share a slug", "stock can't go below zero", "exactly one featured post" can't be — at least not trivially. Each likely needs its own custom encoding in the CRDT's merge logic, or if we still insist on handling all of these at read time, then we will need to invent other such mechanisms like the tombstone idea.
- We also may wish to fire some "event" when we get a conflict like this, so we will need some additional machinery to detect when this happens and fire an event in response. With the server-authoritative approach, we just push a side effect in the guard clause and notify the user in the client's handling code.
Basically, in the server-authoritative model, every one of those is the same boring guard clause in the same totally-ordered code path.
A related, narrower point that's more specific to YJS is permissions. In practice YJS is usually deployed with a relay server anyway, and that server can gate who connects and enforce coarse rules like read-only access. But fine-grained, intent-level authorization is awkward, because a YJS update is a set of struct insertions positioned relative to their neighbors rather than a statement of intent — deciding whether one is allowed means applying it and looking at what changed. In the reducer model the operation is the statement of intent, so "may this user retire a tag?" is, once again, an if statement.
When a CRDT is the right call
- collaborative text — concurrent edits inside a text field need purpose-built intention-preserving merging, which is something that many CRDTs have long optimized for. If your app has a collaboratively edited document or rich-text field, use a text CRDT for it — embedding one inside an otherwise server-authoritative app is a perfectly reasonable hybrid.
- fully peer-to-peer models — a protocol that supports fully commutative edits by design is "fairer": you don't have to crown a particular node as authoritative, leaving the other nodes more likely to have their work trampled on.
Conclusion
If you're building real-time state sharing for a connected web app, you can get a synchronous-feeling editing experience with cheap network replication behind it from one pure reducer and a server that orders operations. The code in the reducer is mostly what you'd have written anyway for the single-player case, if you're already taking a declarative approach to UI — and every correctness rule your application accretes is an ordinary if statement in one totally-ordered code path, which is also where permissions naturally live. The costs are real but legible: clients render a rollback-able guess, your server needs to run the shared reducer (in practice, JavaScript), the shared state has to fit in memory, and there's no offline story (peer-to-peer, though, only takes crowning one client as the authority).
CRDTs solve a harder problem — convergence with no authority at all — and if you need what that enables (collaborative text, P2P), you should use them. But that power isn't free: invariants that span pieces of state have to be designed into the merge behavior rather than written as a check, and most applications are made of exactly those invariants. When you have a server anyway, letting it pick the order is the simplification that makes everything else ordinary code.