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.

source side (leader)
import { createRecorder } from 'remjs';

const recorder = createRecorder({
  onOps: (ops) => transport.send(ops),
});
recorder.start();
// every click, timer, fetch, Math.random() is captured
follower side (replica)
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.

the replication model
          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.

example op stream
{ "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.