Realtime & workers
authored by claude, rubber stamped by Bryan MacLee
TL;DR: the realtime/queue/worker plumbing that every mainstream stack treats as a vendor decision (socket.io vs. Pusher vs. Liveblocks; BullMQ vs. Inngest vs. Trigger.dev; web worker boilerplate vs. Comlink) is one language primitive in scrml. <channel> for shared reactive state. Nested <program> for separate execution contexts (web worker, sidecar process, WASM module, server endpoint). Both compile to plain runtime calls; neither pulls in a library.
Pick any production-ready stack and trace the path from "I want multiple users to see the same value update in real time" to "I have a working feature." The shape is consistent: choose a vendor, learn its SDK, wire its client into your reactive system, write the server-side handler in its DSL, decide on the consistency model, learn its reconnection semantics, debug the case where the WebSocket and your local state disagree.
Same shape for workers: choose a worker library or roll Web Worker boilerplate, define the message protocol, serialize/deserialize at the boundary, decide where the worker source lives, set up the build pipeline that bundles the worker code separately, debug the case where the worker crashed and your main thread doesn't know.
The bet underneath scrml's design here is that realtime state and worker execution contexts are not vendor problems — they are placement problems, and the compiler is already doing placement. Adding two more placement targets (a synced channel; a nested execution context) costs an order of magnitude less complexity than picking the right vendor.
Part 1: realtime state via
<channel>
A
<channel>
block (SPEC §38) declares state that's synchronized in real time across connected clients. It lives inside the entry-file
<program>
(a sibling of <page> declarations, per the v0.3 §38.1 placement), and its body uses the same V5-strict declarations the rest of the language uses.
<program>
<channel name="cursors">
<positions> = [] // synced across all connected clients
</>
function moveCursor(x, y) {
@positions = [...@positions.filter(p => p.uid != @user.id), { uid: @user.id, x, y }]
}
<svg onmousemove=moveCursor(event.clientX, event.clientY)>
${ for (p of @positions) { lift <circle cx=${p.x} cy=${p.y} r="5"/> } }
</svg>
</program>
Three things to notice. First, there is no
@shared
modifier on
@positions
— the cell is synced because it's structurally inside the channel block. Placement IS the contract. The pre-v0.next syntax that required
@shared
was retired at the D3 rewrite for exactly this reason: the modifier was redundant signal.
Second, the read site and write site for
@positions
look exactly like the read/write site for any other reactive cell. The
${ @positions.forEach(…) }
re-renders automatically when the channel pushes an update. There is no subscribe / unsubscribe lifecycle; no callback registry; no manual diff. The reactive system that handles a local
<count>
handles this too — the only difference is where the change is sourced.
Third, there is no vendor in this picture. The compiler emits the WebSocket server endpoint, the client connection, the message protocol, the reconnection logic. The
scrml dev
server hosts it. The
scrml build
output deploys it. There is no library to import, no API key to pass, no per-message billing tier. (There are honest trade-offs at scale — a Bun server isn't a 100k-concurrent-connections vendor service — but for the "I want users in the same document to see each other's edits" case the primitive is sufficient.)
What the compiler tracks
- Per-channel reactive dep graph. Same dep-tracking pass as local state — reads register, writes propagate — extended over the WebSocket boundary.
-
Reconnect semantics. A
<program channel-reconnect=>attribute sets the project-level default (S81 amendment). Per-channel override at the channel block. -
Server-authoritative writes. A channel cell that the server treats as authoritative rejects client-side writes the same way the
servermodifier does on a regular state cell. (See the mutability contracts article for the five-layer authority model this fits into.) -
Lifecycle handlers.
onserver:…andonclient:…hooks for join / disconnect / per-message logic when the default sync doesn't fit.
Part 2: workers as nested
<program>
scrml's worker primitive is structural: a
<program>
nested inside another
<program>
is an independent execution context. The attribute combination on the nested program decides what kind of execution context (SPEC §43.2):
-
name=only — Web Worker. Compiles tonew Worker()with postMessage transport. -
name=+lang=(non-WASM) — Foreign Sidecar. Subprocess; HTTP or socket transport. -
name=+lang=+mode="wasm"— WASM Module.WebAssembly.instantiate()under the hood. -
name=+route=— Server Endpoint.Bun.serve()route handler.
One structural primitive; four runtime targets. The developer doesn't choose between four worker libraries; they pick the attribute that matches the placement they want.
<program>
<program name="compute">
${ function fibonacci(n: number) -> number {
if (n < 2) return n
return fibonacci(n - 1) + fibonacci(n - 2)
} }
when message(data) {
send(fibonacci(data))
}
</>
<result> = 0
${ function go() {
@result = <#compute>.send(35)
} }
<button onclick=go()>Compute</button>
<p>${@result}</p>
</program>
The
<#compute>.fibonacci(35)
call site looks like a regular function call. The compiler knows
compute
is a nested program, knows
fibonacci
is exported from it, generates the postMessage marshaling, returns a Promise. The unawaited form is a compile error (E-PROG-004) because losing async results is a known footgun.
Shared-nothing scope isolation
Nested programs are fully isolated. No bindings, types,
use,
or
import
declarations cross the
<program>
boundary. A reference to a parent-scope name from inside a nested
<program>
is a compile error (E-PROG-003).
Types needed in both parent and child are extracted to a shared module and imported independently from each side. The boundary is real, not a convention — the compiler refuses to elide it.
Supervision is an attribute
Workers fail. The
restart=
attribute on the nested
<program>
sets the supervision strategy (
"always"
/
"never"
/
"on-error").
max-restarts=N
and
within=S
cap restart frequency. Lifecycle events surface in the parent program via the
when
grammar (§46):
<program name="ingest" restart="always" max-restarts="5" within="60">
${ // long-running ingest loop ... }
</>
when message from <#ingest> (data) { @lastBatch = data }
when error from <#ingest> (e) { @status = .Crashed }
when terminate from <#ingest> { @status = .Terminated }
That's the supervision tree. No library; no separate config file; the supervision strategy lives next to the worker declaration. A reader scanning the parent program can see exactly what happens when the ingest worker crashes.
Why the two primitives compose
The reason these live in the same article: many production patterns use both at once. A document-collaboration feature uses
<channel>
to share cursor positions and a nested
<program name="diff">
web worker to compute operational transforms off the main thread. A real-time dashboard uses
<channel>
for live event push and a
<program name="aggregate" route=>
server endpoint for batched rollups.
Both primitives compile down to plain runtime calls; both stay in the same file (or a small set of files) the developer is already reading; both surface their failure modes as compile errors or
when error
handlers, not as runtime exceptions that surface in vendor dashboards.
What you give up
The honest list:
-
Massive-scale managed realtime.
<channel>runs on the Bun server scrml deploys; it isn't a 100k-concurrent-connections vendor service with edge POPs. If you're building Figma-scale collaboration, you're going to want a real CDN-backed realtime tier underneath. The<channel>surface gets you to product-market fit; scaling past it is a separate question. - Vendor-specific features. Pusher-style presence channels, Ably-style message history, Liveblocks-style awareness API — none of these are built-in. The primitive is "shared reactive state"; features above that layer are app code.
-
Mature worker-pool tooling.
BullMQ has years of operator experience with delayed jobs, retries, priority queues, observability dashboards. Nested
<program>gives you the supervision tree; the rest is application code on top of it.
All three trade-offs are intentional. The bet is that "most apps don't need vendor-grade realtime / vendor-grade queues" and "the apps that DO grow into them will already have the structural primitive in place to integrate with."
The shape of the bet, again
Realtime and workers in mainstream stacks are vendor-shaped because the language doesn't have a concept of placement. The runtime is a single thread, in a single process, on a single side of the network. Anything else is a library that pretends to extend the runtime.
scrml's compiler is already doing placement for the server/client split — deciding what runs where based on the call site. Extending that to a synced channel and a nested execution context isn't a different mechanism; it's the same mechanism with two more placement targets. The language primitives are smaller than the vendor surfaces they replace, and they compose with each other and with the rest of the type system. That's the whole bet.