dalien-signals
dalien-signals is a data-oriented fork of alien-signals, a fast signals reactivity library. Its dependency graph is stored in a single Int32Array memory arena, like a contiguous array of structs in C, rather than as JavaScript objects.
- Improved performance for very large graphs through cache locality.
- Signals with amortized
O(0)allocation viasignalId(value): number, freed withdispose(signalId). - Pre-allocate memory to avoid resize copying/re-allocation pauses during application start-up.
- Trade-off: no advantage on workloads with small homogeneous graphs, where object-based graphs would be JIT specialized favorably.
All UI on this page is vanilla JS with dalien-signals.
Stress test: one signal per pixel
Every pixel is a signal or computed, plus an
effect to paint it. The canvas alternates 64-row bands of
two shapes of dependency that stress opposite ends of a signals
implementation.
- deep bands (blue→cyan): each pixel reads one parent in the row above — a source write re-validates a chain one pixel wide, 64 levels deep.
- wide bands (violet→amber): every ~32nd source pixel is a hub read by thousands of cells — one hub write is a broad wave, one level deep.
Frame compute times each frame's write batch — mark the cones of dependents, recompute them, run the queued render effects. An adaptive controller sizes the batch to recompute ~10% of the graph each frame, so the load scales with the graph.
Every cell rounds its output to a coarse step, so fading ripples recompute to the value they already held and propagation stops there — the equality cutoff at work across the whole field.
The three primitives
dalien-signals keeps calculations and callbacks in sync with changing values. It provides three main primitives:
-
A
signalstores a value. Call it with no argument to read it, or with an argument to write it — likeuseStatein React. -
A
computedderives and caches a value from signals or other computeds — likeuseMemo, or a selector in Redux. -
An
effectruns a function now, then again when a value it read changes — likeuseEffect.
Dependencies are automatic. While a computed or effect runs, the library records every signal and computed it reads — reading inside a computed or effect is the subscription.
import { computed, effect, signal } from "dalien-signals";
const count = signal(0);
count(2); // set state
const doubled = computed(() => count() * 2);
const isEven = computed(() => count() % 2 === 0);
doubled(); // 4, cached until count changes
const stop = effect(() => {
const original = document.title;
document.title = `doubled = ${doubled()}`;
return () => {
document.title = original;
};
});
count(100); // the effect reruns: "doubled = 200"
stop(); // stop the effect; cleanup restores the title
When count changes, the effect is queued and pulls
doubled up to date; isEven recomputes only
the next time it is read. The widget's effect runs
cell counts an effect that reads both computeds: it is queued once per
change and re-runs only when one of them produces a
new value. Stepping from 1 to 3:
doubled changed, but even? was
no before and after — equality cutoff, the same rule that
stops the field's ripples.
How a write becomes an update
Recalculating every downstream node on each write would waste work. Recalculating dependents immediately could also run an effect more than once, or before all of its inputs are current. dalien-signals instead uses a push-pull algorithm: a write does not recompute anything by itself; it records what may have changed, cheaply, and the work happens later, on demand.
-
Push staleness. Writing a signal marks it
Dirtyand walks its subscriber lists, marking each downstream computed and effectPending— "something under you may have changed." No user callback runs during this graph walk; it's a few integer writes per node. - Queue effects. Any effect the walk reaches goes into a queue, once, no matter how many paths led to it.
-
Pull and verify. Before a computed returns its value, it
checks its dependencies and recalculates if necessary. A queued
effect reruns and pulls each computed it reads up to date. A
Pendingchain resolves top-down; if a node's inputs all came out equal, its value has not changed, and the update stops along that path. - Stamp the verdict. Every committed write increments one global version counter. When a computed finishes verifying, it stores that version; a later read compares the two numbers, and if they match, the value is provably current — the read returns in a couple of loads without walking anything.
Below, two signals feed a subtotal, a total, and one effect; each button narrates the operation it performs. A batch coalesces two writes into one effect run; an unchanged write produces no work; disposing the effect makes the computeds drop their own edges — an unwatched computed keeps no bookkeeping.
Memory layout: eight integers per node
Signal libraries typically allocate an object per node, wired with
references the garbage collector traces on every collection.
dalien-signals stores the whole graph in one
Int32Array — an arena of 32-byte records, eight
int32 slots each. A node is the index where its record
starts:
- 0 · Flags
- kind tag + state bits: Mutable, Watching, Dirty, Pending, …
- 1 · Deps
- head of the dependency list — points at a link record
- 2 · DepsTail
- the re-track cursor into that list
- 3 · Subs
- head of the subscriber list — also a link record
- 4 · SubsTail
- tail of the subscriber list
- 5 · Gen
- generation counter, so stale ids of freed records miss
- 6+7 · Version
- one float64 (via a shared-buffer view): "verified as of global version N"
Edges are records in the same array: a link record names a dependency, a subscriber, and the four neighbors threading it into both lists. Walking a list means following integers from slot to slot — nothing for the collector to trace, and adjacent records share cache lines. A free list chains unused records together, so disposed nodes and edges are recycled in place; values, getters, and callbacks live in ordinary side arrays indexed by the same id. The memory tables on this page are that array decoded row by row; amber cells changed on the last action.
The default arena reserves 64 MB of address space — 2,097,152 records
of 32 bytes. Large zero-filled buffers are demand-paged: allocation
reserves virtual address space, but resident physical memory grows
only as pages are touched. growCapacity()
raises the reservation for bigger graphs — the size bar above calls it
before building each field — and the arena also grows itself when it
fills.
Example: Spreadsheet
Each cell is a signal (the typed text) plus a computed (its value).
Formulas start with = and are plain JavaScript;
GET('A:1') reads another cell. The formula runs inside
the cell's computed, so every GET records a real
dependency edge — edit a formula and the memory view shows link
records appearing and aging out.
Benchmarks, in this tab
The milomg js-reactivity-benchmark suites — sbench, kairo, cellx, dynamic — run against four of the selector's libraries — dalien, alien, preact, and reactively — using the benchmark's own adapters. Each (suite, library) cell runs in a fresh Worker, so no library inherits JIT state, arena contents, or main-thread contention. One round each — expect a few minutes; published numbers come from CI (interleaved rounds, isolated processes, medians).