Target audience: Back-end developers curious about reactive systems
Think of reactivity like spreadsheet formulas for your back-end. When you update a cell in Excel, all dependent formulas recalculate automatically. Reactive back-ends work the same way: when your data changes, all derived views update automatically and push fresh data to clients—without you manually tracking what needs to refresh.
Traditional back-ends require you to manually:
- Track which data depends on what (dependency graphs)
- Figure out what to invalidate when something changes
- Wire up notification logic to tell clients about updates
- Handle edge cases where clients see stale data
Reactive back-ends eliminate this boilerplate. You define your data and relationships once; the runtime handles propagation automatically.
This repo demonstrates SkipRuntime—a reactive engine with ReScript bindings—through a simple working example.
We build a tiny service with:
- Input collection: A key-value store (
input) that starts withfoo → "bar" - Reactive resource: An
echoview that automatically mirrors whatever's ininput - Two APIs:
- HTTP for reading data and making updates
- SSE (Server-Sent Events) for streaming live updates to clients
- Read
echo→ get{foo: "bar"} - Update
input→ setfoo → "baz"and addbar → "qux" - Read
echoagain → automatically get{foo: "baz", bar: "qux"} - Subscribe via SSE and watch updates arrive in real-time as they happen (no polling): the runtime pushes the updated entries to you without another request.
No manual invalidation code. No cache busting. No diffing logic. The runtime tracks dependencies and pushes updates automatically.
npm install
npm run build
node examples/LiveClient.res.jsExpected output:
server: starting wasm service on ports 18080/18081…
server: service started
live client: initial getAll [ [ 'foo', [ 'bar' ] ] ]
live client: after update getAll [ [ 'bar', [ 'qux' ] ], [ 'foo', [ 'baz' ] ] ]
live client: subscribing to http://127.0.0.1:18081/v1/streams/...
live client: SSE chunk event: init
id: …
data: [["bar",["qux"]],["foo",["baz"]],["sse",["ping"]]]
server: service closed
Notice: We never wrote code to update echo. It happened automatically when input changed.
- Define relationships once: "Echo mirrors input"
- Runtime tracks dependencies: Skip knows
echodepends oninput - Write triggers propagation: Update
input→ runtime recomputesecho→ clients get fresh data - Subscribe for live updates: Open an SSE stream and receive updates as they happen, no polling needed
The Skip runtime handles all the plumbing—dependency tracking, incremental recomputation, and streaming. You just declare what depends on what.
- Works on current Node via wasm (no native runtime here; native is Linux-only). Runtime recommends Node >=22.6 <23 for native builds, but the wasm path has worked on newer Node in practice.
- Two available ports (defaults: 18080 for HTTP, 18081 for SSE).
npm installto grab Skip packages.
npm testbuilds and runs the live client (examples/LiveClient.res.js) on ports 18080/18081.
Skip’s service graphs are built from composable operators on collections. The most important one in this repo is reduce.
Conceptually, its type is:
reduce : collection<k, v> -> reducer<v, a> -> collection<k, a>
On the Skip side (see bindings/SkipruntimeCore.res), that's exposed as:
EagerCollection.reduce : (~params=?, collection<'k, 'v>, reducer<'v, 'a>) -> collection<'k, 'a>- where a reducer is built as
Reducer.make(~initial, ~add, ~remove=?)
A reducer is a small state machine:
initial(params) : option<'a>– produce the starting accumulator (orNoneto say “no value yet”)add(acc, value, params) : 'a– incorporate a newly-seen value into the accumulatorremove(acc, value, params) : option<'a>– forget a value; returningNonetells the engine “I can’t update incrementally for this change, please recompute from scratch for this key.”
EagerCollection.reduce maintains one accumulator per key. For each key k, the runtime:
- Starts from
initial(or a recomputed value). - When dependencies change, computes the old multiset of contributing values and the new multiset.
- Calls
removeonce for each value that used to contribute underk(theoldslice). - If all
removecalls returnSome(acc'), callsaddonce for each value that now contributes underk(thenewslice). - If any
removereturnsNone, discards the accumulator and recomputes it from scratch viainitial+addover all current values fork.
The contract for reduce is:
- Purity:
initial,add, andremovemust be pure and depend only on their arguments. - Correctness under change: For any key, starting from a valid accumulator for some multiset of values and applying the runtime’s sequence of
remove/addcalls (or the full recompute path whenremovereturnsNone) must yield the same result as recomputing from scratch over the current values.
This contract lets the runtime maintain derived collections incrementally: when inputs change, only affected keys are updated, and for each key the engine updates the stored accumulator using your remove/add implementation or falls back to a full recompute if you signal that incremental updates are too hard.
After LiveClient, examples/LiveHarness.res + LiveHarnessService.* illustrate how reduce works.
The service exposes:
numbers: input collectionstring → number, initially[a→1, b→2, …, j→10].doubled: each number multiplied by 2 (demonstratesmap).sum: total of all numbers under key"total"(demonstratesreduce).
In LiveHarnessService.ts:
class SumReducer implements Reducer<number, number> {
initial = 0;
add(acc, value) { return acc + value; }
remove(acc, value) { return acc - value; }
}The sum resource is built as numbers.map(TotalMapper).reduce(SumReducer), where TotalMapper emits every value under the single key "total".
When numbers["c"] changes from 3 to 5:
- Mapper runs once for the changed key
"c". - Reducer sees full slices for key
"total":old= all 10 previous values[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]new= all 10 current values[1, 2, 5, 4, 5, 6, 7, 8, 9, 10]
- Engine calls
remove10 times, thenadd10 times.
The sum is correct (55 → 57), but the reducer processes O(n) values per update—not just the changed value.
Skip's reactivity is per collection and per key. When any upstream key changes, the reducer for "total" sees the entire old and new contribution lists for that key. There's no finer-grained "just this value changed" signal at the reducer level.
For truly O(1) aggregates, subscribe to the reactive collection via SSE and maintain the aggregate client-side:
// Subscribe to SSE stream for numbers collection
let streamUrl = await Client.getStreamUrl(opts, broker, "numbers")
ClientSum.subscribe(streamUrl)
// In ClientSum module: O(1) update when SSE delivers changes
let applyUpdate = (key, newValue) => {
let oldValue = state.numbers->Dict.get(key)->Option.getOr(0.)
state.total = state.total -. oldValue +. newValue
state.numbers->Dict.set(key, newValue)
}The harness includes a ClientSum module that subscribes to numbers via SSE. When the server pushes updates, the client applies them in O(1) time—no polling, no re-fetching the whole collection.
Run:
npm run build && node examples/LiveHarness.res.jsSkipruntimeCore.res: Core types, collections (EagerCollection,LazyCollection), operators (map,reduce,mapReduce),Mapper/Reducer/LazyComputefactories, notifiers, service instances.SkipruntimeHelpers.res: HTTP broker (SkipServiceBroker), built-in reducers (Sum,Min,Max,Count), external service helpers (PolledExternalService,SkipExternalService), leader-follower topology (asLeader,asFollower).SkipruntimeServer.res:runServiceto start HTTP/SSE servers.SkipruntimeCoreHelpers.mjs: JS helpers for class constructors, enums, and SSE utilities (subscribeSSEfor streaming).
LiveClient.res: Main demo—starts a service, reads/updates via HTTP, subscribes via SSE.LiveHarness.res+LiveHarnessService.ts: Demonstratesmapandreducesemantics. IncludesClientSum, a client-side O(1) accumulator that subscribes to SSE.Example.res: Binding smoke test—LoadStatus, errors, mapper/reducer wiring—without starting the runtime.NotifierExample.res: Demonstrates notifier callbacks receiving collection updates and watermarks.LiveService.ts: Minimal service definition forLiveClient(echo resource mirroring input).
Reactive back-ends let you declare what should happen, not how to make it happen. You avoid manually wiring update logic, and clients never see stale data. This example shows the concept end-to-end in ~80 lines of actual service and client code.