remjs
Event loop replication
A running JavaScript program is a state machine driven by its event
loop inputs — clicks, timers, network responses, reads of
Math.random and Date.now. remjs captures
those inputs on a leader and replays them on a
follower, so two runtimes running the same code
reach the same state without any coordination of their own.
How it works
createRecorder installs patches at the event loop's
entry points — addEventListener, setTimeout,
fetch, Math.random, Date.now,
localStorage — and records every input that crosses
into the runtime. Ship the resulting op stream over any transport,
and createPlayer drives the other runtime's loop with
the same inputs in the same order.
import { createRecorder } from 'remjs'; const recorder = createRecorder({ onOps: (ops) => transport.send(ops), }); recorder.start(); // every click, timer, fetch, Math.random() is captured
import { createPlayer } from 'remjs'; const player = createPlayer(); transport.onMessage = (ops) => { player.apply(ops); // events dispatch, randoms seed, clocks align };
Transport-agnostic. The library gives you a recorder and a player;
you decide how to move bytes. The demo below uses postMessage
between iframes — both sides run the same shuffleboard code.
Live demo
Shuffleboard mirror. Same code runs in both iframes. The left court
is the leader: its clicks, pointer moves, timer fires,
and Math.random reads cross the event loop and are
captured as ops. The right court is the follower:
it receives the op stream, replays the events onto its own DOM, and
returns the leader's recorded random values to its own code. No
leader-to-follower state transfer — the follower recomputes state
from the same inputs.
Open full demo (adds pause, step, speed control, live JSON readout, perf stats) · View source
What's captured
remjs intercepts the complete event loop input surface. Every source of non-determinism is recorded so replicas produce identical execution — no framework hooks, no source transforms needed.
DOM events
Clicks, keypresses, input, scroll — every user interaction.
Timers
setTimeout, setInterval, requestAnimationFrame — when callbacks fire.
Network responses
fetch / XHR responses — status, headers, body recorded.
Randomness
Math.random(), crypto.getRandomValues() — exact values recorded.
Clock
Date.now(), performance.now() — timestamps aligned across replicas.
Storage
localStorage, sessionStorage reads and writes.
Architecture
The formal model is a replicated state machine. Two runtimes reach the same state iff one invariant holds: every non-deterministic value a handler reads on the leader is available on the follower before the trigger that runs the handler.
LEADER FOLLOWER ┌──────────────────────┐ ┌──────────────────────┐ │ patches observe │ │ player drives │ │ addEventListener │ op batch │ dispatchEvent │ input │ setTimeout │ ─────────────► │ queue sync oracle │ ──────► │ fetch │ (any transport) │ resolve async │ │ Math.random │ │ oracle await │ │ Date.now │ │ │ │ localStorage │ │ │ └──────────────────────┘ └──────────────────────┘ recorder: one batch = one event loop task
Inputs split by JavaScript's sync/async boundary.
Sync oracles (Math.random,
Date.now, localStorage.getItem) must return
immediately, so the follower pre-queues values before the handler runs.
Async oracles (fetch, XHR, WebSocket)
return a Promise the follower holds until the leader's matching op
arrives — no native fallback, no silent divergence.
Batches are task-granular. One event loop task plus its microtask
drain — the trigger, its synchronous handler, and any
await resumptions chained from it — lands in one wire
batch. That keeps handler causality intact across async code without
any op-level grouping or wire-format markers.
remdom reifies the output surface (DOM mutations). remjs reifies the input surface (event loop tasks). Together: full round-trip replication.
Wire format
Plain JSON. No tagged values, no special encoding. Each op is one event loop input.
{ "type": "event", "eventType": "click", "targetPath": "button#inc", "detail": {"clientX":140} }
{ "type": "random", "source": "math", "values": [0.7291] }
{ "type": "clock", "source": "dateNow", "value": 1712345678000 }
{ "type": "timer", "kind": "timeout", "seq": 3, "scheduledDelay": 1000 }
{ "type": "network", "kind": "fetch", "url": "/api", "status": 200, "body": "{...}" }
Non-goals
remjs defines the op protocol. These are implementation concerns:
Transport
No sockets, no auth, no framing. You pick the wire.
Consensus
Total-order broadcast, CRDTs, Lamport clocks — for multi-writer topologies, put them above remjs.
Topology
Star, mesh, gossip — remjs is the substrate, not the routing.
Framework integration
Operates below all frameworks. No React/Vue/Svelte plugins.