Skip to content

Signal Is Not a Reactive Primitive

Brian Takita
Authors:Brian Takita
Posted on:July 19, 2026

Signal is a composition of a memoized slot and a puller effect... five lines, in every binding. The frontend ecosystem promoted it to a primitive, and the TC39 proposal quietly didn't. Plus: why weak back-edges are an ergonomics driver before a performance one, what non-GC languages pay instead, and how naming ambiguities are a reliable way to find architecture bugs.

Signal Is Not a Reactive Primitive

Here is the entire eager-reactivity implementation in lazily-rs:

pub fn signal<T, F>(&self, compute: F) -> SignalHandle<T>
where
    T: PartialEq + 'static,
    F: Fn(&Context) -> T + 'static,
{
    let slot = self.memo(compute);
    // Eager puller: re-materializes the slot after every invalidation.
    let effect = self.effect(move |ctx| {
        let _ = ctx.get_rc(&slot);
    });
    SignalHandle::new(slot, effect)
}

A memoized slot, and an effect that reads it. That is all a Signal is.

One note before the argument, because that code is now historical. lazily no longer ships signal() or SignalHandle ... the family deleted the name and writes the same composition as computed().eager() today. Nothing about what it is changed; the two nodes are still a computed and a puller. If anything the new spelling makes the case for me, and I come back to it at the end. Read the snippet as the construction, not the API.

The claim in this post is falsifiable, which is the only reason it is worth making: if Signal were a primitive, you could not build it out of the other primitives. You can. In five lines. Seven of the family's eight bindings do exactly that, and the eighth is the interesting case ... it welded eagerness in instead, and paid for it.

Everything else here is commentary on that one fact... and on why the naming got in the way of seeing it.

The receipts

lazily is a reactive graph implemented across eight languages against a shared specification, so the claim is checkable in a way that arguments about reactive design usually are not.

The node arena has three variants. Not four:

pub(crate) enum Node {
    Slot(SlotNode),
    Cell(CellNode),
    Effect(EffectNode),
}

A Signal never appears in the graph. SignalHandle is a pair of ids... a slot handle and an effect handle... and the runtime never learns that the pair means anything.

Seven of the eight bindings build it out of the other two primitives. The C++ version is the rest of the family in miniature ... memo<T>() for the slot, an effect_void that reads it, and a handle holding the pair:

template <typename T, typename F>
SignalHandle<T> signal(F&& compute) {
  auto slot_handle = this->memo<T>(std::forward<F>(compute));
  auto eff = effect_void([slot_handle](ContextImpl& ctx) {
    ctx.get_rc<T>(slot_handle);
  });
  return SignalHandle<T>(slot_handle, eff);
}

Different language, same five lines, no privileged access to anything. That is what a composition looks like when nobody is tempted to make it a primitive.

The eighth binding is the counterexample, and it is the strongest evidence here. lazily-go ships a Signal too, but not as a composition. Its Signal[T] is its own reactive node, backed by a signalSlot[T] that subclasses the ordinary slot and overrides onInvalidate to eagerly re-pull. No effect exists anywhere in it. Eagerness is welded into the invalidation path rather than supplied by a separate node that reads.

And it pays exactly the tax this post predicts. The composed version holds two ids. The welded version holds its own value, active, stale, and recomputing fields, duplicating state the backing slot already has. It needs a markStale entry point that exists only so a teardown cascade can defer the eager re-pull to the next read, with a comment explaining why the re-pull had to be skipped there. None of that machinery exists in the seven bindings that compose, because disposing an effect is just disposing a node, and a slot that nobody pulls is already lazy by default.

So the family contains both experiments, run independently in different languages: build eagerness out of the primitives, or build it into one. The composed version is five lines and needs no teardown special case. The welded version needed four extra fields and its own disposal path to reach the same behavior.

The tax turned out to be measurable, and larger than the field count

Everything above was an argument from code shape until the specification grew four normative clauses for signals and a fixture for each. One of them is the interesting one:

Inside batch(run), a signal whose dependencies are written N times MUST re-materialize once, at the outermost batch exit — not once per write.

A composed signal satisfies this without anyone implementing it. The puller is an ordinary effect, effects are scheduled rather than run inline, so N invalidations inside a batch coalesce into one scheduled run at the flush. It is free.

A welded signal cannot satisfy it, because re-pulling from an invalidation handler happens earlier than the flush. So the compute count scales with the number of writes.

Replaying that one fixture across the family found four bindings doing exactly that, by two unrelated routes. Three of them — Python, Dart, and Go — had independently grown the same signal-specific slot subclass whose invalidation handler re-pulls inline, with no effect anywhere in the construction. Nobody coordinated it; the same wrong design was reached three times in three languages. The fourth, Zig, had a correctly composed signal and failed anyway, because its set_cell flushed unconditionally while batch only nested a depth counter. That one is worth stating on its own: a composition is not sufficient if the batch boundary does not gate the flush.

The measured tax, on a two-write batch: two computes where the contract says one. On three writes across two cells: five where the contract says three. Every value was correct in every case. That is why it had shipped in four bindings and survived eleven other fixtures, a Lean model, and a load-test harness — a signal that recomputes twice as often as it should returns exactly the same numbers as one that doesn't.

Which is the part that revises the argument I was making before I had numbers. I had framed the cost of promoting a composition as duplicated state and duplicated defects — four extra fields, a bespoke teardown path, the second place every future contract clause has to be satisfied. All true, and all under-stated. The real cost is that welding eagerness into invalidation puts it upstream of the coalescing machinery, so the thing loses every optimization the graph performs at its boundaries. The composition doesn't merely cost less to maintain. It is the only version that gets batching at all.

And the reason nobody caught it for months is the same reason this post exists: an eager signal and a lazy memo are indistinguishable by value. Every assertion in the conformance corpus compared values, so no fixture could separate signal() from memo(), let alone a coalescing signal from a per-write one. Pinning it needed a new observable — a cumulative count of how many times each compute actually ran. The distinction between the two constructs is entirely a question of when compute runs, which is the thesis of this post stated as a test-harness requirement.

An earlier version of this section claimed the opposite: that Go and C++ shipped no Signal at all, and that nobody had filed an issue about it. Both halves were wrong. Both bindings had shipped one for two weeks before I wrote that, the spec's own coverage table marked it present in all eight, and a CI check guards that table against drift. The argument from silence was worthless independently of the facts ... nobody files issues against a library with no users, so absence of complaint measures nothing. I reached for it because I had read something rather than run something, which is the failure this family keeps finding in itself, and the correction came from a reader who went and looked.

The specification already said so, in language written well before this post:

Signal is a derived construct, not a core primitive. It is Signal ≡ Slot.eager... a memo Slot plus a puller Effect... It composes the core primitives; bindings expose it as a convenience, not as a distinct kind of node.

So the architecture had this right. The API surface did not... and that gap is the interesting part.

Eagerness is a scheduling policy, not a data structure

This is the load-bearing idea.

Whether a derived value is lazy or eager is not a property of what kind of node it is. A memoized slot is neither lazy nor eager on its own... it is a cached computation with a dirty bit. What makes it lazy is that nothing reads it until you do. What makes it eager is that something else reads it first.

The something-else is an effect. That is the whole mechanism. Eagerness is a consequence of who pulls, and when, not of what the node is made of.

Which is why it cannot be a primitive. It is a policy applied to a primitive. Calling it a primitive is a category error of the same shape as treating "cached" as a kind of function rather than a thing you do to one.

The three actual primitives fall out cleanly:

Primitive Role
Cell a mutable source value
Slot a lazily memoized derivation
Effect a scheduled observer

Everything the reactive ecosystem calls a signal is one of those three, or a composition of them.

Lazy is the primitive, and the composition only runs one way

There is a stronger version of this, and it is the part I would defend hardest.

Laziness is the absence of a scheduler. A memoized slot needs nothing outside itself: a cached value, a dirty bit, and a rule for what to do when read. Eagerness needs an additional actor whose whole job is to read the thing before you do.

So the composition is not symmetric:

eager  =  lazy + puller
lazy   ≠  eager - anything

You can always add a puller to a lazy memo. You cannot subtract one from an eager primitive, because there is nothing there to remove... the scheduling is welded in. That asymmetry is what "primitive" means at a given layer. Lazy is the irreducible one.

This is not a lazily opinion. It is what happens every time someone sits down and asks what the irreducible pieces are:

System Derived primitive Computation Propagation policy
TC39 proposal Signal.Computed lazy, cached none built in... Watcher is the hook
rmemo reactive memo lazy on first read, cached eager ... set drains dependents by level
lazily Slot lazy, memoized lazy ... invalidate, recompute on read

Disclosure, because it changes what this table is worth: I wrote rmemo as well as lazily. The name is short for "reactive memo." So this is not three independent designs converging — it is a standards body and one person who built the same primitive twice, on purpose, with opposite propagation policies. Take the TC39 row as outside corroboration and the other two as a controlled experiment by an interested party.

I think the controlled experiment is actually the more useful of the two, and I would rather say so than let the table imply a consensus that isn't there. Two independent authors would tell you the decomposition is discoverable. One author building it twice with the propagation policy deliberately reversed tells you the policy is separable from the primitive — which is the specific claim this post is making, and the thing a coincidence of designs would not establish.

The last column is where that pays off. rmemo and lazily share the same primitive and disagree about scheduling. rmemo drains eagerly on write; lazily marks dirty and waits for a read. That disagreement is expressible because the policy is not welded into the primitive. If eagerness were primitive, these would be two different data structures rather than two policies over one.

And the TC39 row makes the same point by omission: it ships the primitive and declines to ship any policy at all.

The TC39 proposal is the sharpest case, because a standards body has the strongest incentive to include whatever people actually need. It ships Signal.State, Signal.Computed, and Signal.subtle.Watcher... and deliberately no effect(). Frameworks are expected to build eager behavior on Watcher so they can schedule their own work.

Read that as a design statement rather than an omission: the standard is saying that scheduling is not its business, which is the same claim as eagerness is not a primitive. The eager thing the frontend ecosystem calls a signal is not in the proposal. It is what you get when a framework attaches a watcher to a computed.

rmemo pushes the point one step further and defines the source in terms of the memo as well:

The primitive is a reactive memo. A reactive signal is a reactive memo that has a public .set function.

So in that decomposition there is exactly one derived construct, and both "signal" senses... the writable source and the eager derivation... are described as memo plus something.

That is a more aggressive reduction than the one I would make now, which is worth saying plainly given I wrote both: lazily keeps Cell as a primitive rather than deriving the source from the memo. Two attempts by the same person, and the later one walks the reduction back one step. Where they agree — that the eager derived value is not primitive — is the part I would defend; where they disagree is a live question I have answered two different ways.

"Signal" means at least three different things

Before arguing that the ecosystem got it wrong, it is worth admitting that the ecosystem is not saying one thing.

In SolidJS, Preact, and Angular, signal() is a writable source cell. createSignal hands back a getter and a setter. Preact's signal() has a mutable .value. That is a Cell, and a Cell genuinely is primitive. These frameworks are not wrong... they are using the word for a different object than lazily does.

In lazily, Signal is an eager derived value. Not writable. Not a source. A memo plus a puller.

In the TC39 proposal, Signal is a namespace. This is the part worth sitting with, because a standards body had to decide what the irreducible pieces are, and its answer decomposes the same way the argument here does:

  • Signal.State... a read-write signal. A Cell.
  • Signal.Computed... a derived signal. A Slot.
  • Signal.subtle.Watcher... a low-level mechanism for observing changes.

And two details that make the point sharper than any argument I could construct. First, the proposal is explicit that computeds are lazy:

Computations are lazy, meaning computed Signals aren't calculated again by default when one of their dependencies changes, but rather only run if someone actually reads them.

Second, the proposal deliberately ships no effect(). Frameworks are expected to build effects on top of Watcher, so they can schedule their own work.

Read that together: the proposal's primitives are a source, a lazy memo, and a scheduling hook. There is no eager derived primitive in it. The eager thing the frontend ecosystem calls a signal is not in the standard... it is what frameworks assemble out of the standard's pieces.

The proposal is at Stage 1 and will move. But the decomposition is already the three-primitive one.

Naming as a debugging technique

The reason to care about any of this is not taxonomy. It is that naming ambiguities, conflicts, and inaccuracies are a reliable way to find architecture bugs. A name that does not match the thing is usually a name attached to a design that shifted underneath it, and the gap is where the bugs live.

That is not a slogan. In this family alone, five naming problems each turned out to be sitting on top of something real ... and the last one is the most expensive naming bug I have found, because the name was standing in for a measurement nobody had taken.

A name that contradicted the spec

The type that groups nodes for teardown was called ChildContext. Two problems: it is not a context... there is one graph and one arena, and the type holds only a borrow and a list of ids. And "child context" implies nested visibility, which the specification explicitly denies:

Scoping bounds teardown, not visibility.

The name was arguing against the contract on every page it appeared. It is now TeardownScope, obtained from ctx.scope(). The rename did not fix a bug by itself... but it stopped the API from teaching the wrong model to everyone who read it.

A name that described a bug

The method that ends a teardown scope without disposing its nodes was called leak(). Nothing is leaked. The nodes stay reachable, readable, and individually disposable; they revert to plain context ownership, which is the state every ungrouped node is already in. leak() named the baseline as if it were a defect.

It is now disarm()... the scope is armed to dispose at end-of-life, and this turns that off. Same operation. A name that says what happens.

A name that was simply false

This one is a live papercut, found while writing this post:

pub fn dispose_signal<T>(&self, handle: &SignalHandle<T>) {
    self.dispose_effect(&handle.effect);
}

dispose_signal does not dispose the signal. It disposes the puller, which reverts the value to lazy behavior... the backing slot survives and stays readable. That is intentional and documented in the spec. But the name promises teardown and delivers de-eagering, and a caller who trusts the name leaks the slot.

make_lazy would be honest. The naming inaccuracy is the whole bug.

A name that hid a cross-binding defect for months

The best example, because the ambiguity was doing active harm.

The conformance fixtures asserted which observers fired using a key called observed_by. It sounds like it captures observation. It is set-valued... so a binding that fired observers in a different order on every single notification still satisfied it.

Which is exactly what several bindings were doing. Go randomizes map iteration by design, so its observers fired in a fresh order each time. Python's set-backed storage had no order at all. Neither could be detected, because the assertion key could not express the thing that varied.

The fix was a new key, observed_order, asserting an exact sequence... plus a structural guard rejecting any observer fixture that asserts only observed_by. The ambiguity in one identifier is a plausible reason the drift went unnoticed for as long as it did.

A name that stood in for a capability nobody had measured

The most expensive of the five, and the one that generalizes past this family.

Two type names carry capability claims: AsyncContext and ThreadSafeContext. Every audit of "which bindings support async" had been conducted by counting which repos define the type. That is not an unreasonable thing to do. It is also not a measurement, and the difference stayed invisible for as long as nobody opened the files.

One binding's AsyncContext has no dependency graph. Its async slot node carries neither dependents nor dependencies, its async read recomputes unconditionally on every call, and its synchronous read returns a cached value that nothing ever invalidates. It is a stub with a load-bearing name.

The part worth sitting with is how it passed. Conformance fixtures for that area ask whether a write is visible at depth ... and an implementation that never memoizes cannot serve a stale memo. It recomputes from scratch every time, so it answers correctly, for the one reason that disqualifies it. The fixtures were real, the runs were green, and the name was the only evidence anyone had that there was an implementation underneath.

ThreadSafeContext fails differently, and more quietly, because there is nothing broken to find. Two bindings ship the name and mean materially different things by it: one shares a mutex across worker realms while each realm keeps its own graph, the other is a single-isolate reentrancy guard. Both are accurate in their own documentation. Neither is the shared-graph guarantee a reader infers from the shared name. One identifier spanning three genuinely different guarantees is the same defect as signal spanning three different objects, except that here the reader is a person deciding whether their concurrency assumptions hold.

The repair was to stop treating the name as the claim. Capability status is now recorded per binding as measured by reading the implementation, with three values on the concurrency axis rather than a boolean, and with unmeasured written out explicitly wherever nobody has looked yet. Those blanks matter more than the filled cells: the family has already been burned once by a table whose omissions read as conformance when they meant nobody had checked.

Which is the naming lesson at its most literal. A name is a claim, and a claim with no measurement behind it is indistinguishable from a claim with one ... right up until someone asks the type to do the thing it is named after.

What promoting a composition actually costs

The argument so far is that Signal is a composition. Here is the concrete price of pretending otherwise.

When a composition is promoted to a primitive, it stops delegating and starts keeping its own state. In lazily-py, Signal grew its own observer list rather than reusing the backing cell's. So when a real defect was found in Cell.subscribe... a disposer that could silently unsubscribe a later registration of an equal callback... the identical defect had to be found again, diagnosed again, and fixed again in Signal.subscribe. Two commits, same bug, same binding, same day.

That is the tax. Not elegance. Duplicated state, duplicated surface, duplicated defects, and a second place where every future contract clause must be satisfied.

The lifetime question: WeakRef, and what non-GC languages do instead

There is a second place where the ecosystem's model and the kernel's model come apart, and it is worth as much attention as the eager/lazy one.

A reactive graph's reverse edges are strong references. The source holds a list of its dependents so it can invalidate them. That list keeps them alive. So a long-lived source retains every node that ever read it... which means a workload with a constant live set still grows without bound, in both memory and propagation cost. Subscribe and unsubscribe a thousand times against a topic that outlives them all, and the topic accumulates a thousand corpses that it faithfully walks on every publish.

Garbage collection does not save you here, and this is the part people get wrong. The graph is reachable. The collector is doing its job correctly. The leak is in the data structure, not the runtime.

Weak back-edges are the fix, and they are unobservable

If the reverse edges are weak, the problem disappears. Drop your last strong reference to a dependent and it becomes collectible; the source's edge list holds nothing that keeps it alive. Reclamation follows reachability, which is what people assumed was happening all along.

The important property: this is not observable. If nothing holds a computed, nothing can read it. Collecting it cannot change what any program observes. A specification does not have to mention GC timing to permit it... it only has to stop requiring the back-edge to be strong.

That distinction matters, because "WeakRef" gets used for two very different things:

  1. Weak back-edges, an internal representation choice. Unobservable. A pure memory and propagation-cost win.
  2. FinalizationRegistry-driven cleanup, running teardown logic when collection happens. Emphatically observable, and nondeterministic in a way you cannot write a conformance test against. The callback may never run at all.

I have heard the objection to WeakRef in reactive designs made forcefully, and I think it is almost always an objection to (2). It is a good objection to (2). It is not an objection to (1), and conflating them costs a real optimization for no semantic gain.

It is an ergonomics driver before it is a performance one

The memory argument is the easy one to make, and it undersells the case. The bigger cost of strong back-edges is that they force you to expose disposal at all.

Once the graph retains everything that ever read a source, every component that subscribes has to track a handle and call something to release it. That obligation is viral... it propagates to every caller, every wrapper, every framework integration on top. It is also unidiomatic in exactly the languages that need it most: nobody writing JavaScript, Python, or Kotlin expects to manually release a value they have already dropped every reference to.

And disposal APIs are where the bugs live. That is not a hunch. Working through this family's disposal paths turned up, in a single pass: a use-after-free from a node destroyed while still queued for recompute, a double-free from a destructor re-entering teardown, a release-only worklist corruption that silently leaked nodes with edges into freed parents, a missing unsubscribe API that forced the library's own code to reach into private state, and a disposer that would silently unsubscribe a later registration of an equal callback. Every one of those is a manual-lifetime bug. None of them can occur on a path where you drop a reference and the collector does the rest.

That is the ergonomic claim, and it is sharper than the performance one: weak back-edges do not merely make the common case faster, they make a whole bug class unreachable by construction rather than by discipline. The API you do not have to expose is the API nobody can misuse.

The residue is small and worth keeping. You still want explicit teardown for the deterministic case, and subscribe still returns a disposer for callers who need release to happen now. But that becomes the specialist path taken by people who know they need it, rather than an obligation levied on everyone who ever reads a value.

This is not hypothetical: rmemo ships it

rmemo — mine, as noted above — has been doing exactly this in production. It is not independent testimony, so treat the design choice as one author's opinion. What it does carry independently of authorship is measurements, and those refute the two objections people reach for first, because a byte count and a benchmark do not care who ran them.

Its edge registration records both directions with different strengths: the dependent holds a strong reference to its dependency, so a value you are still deriving from cannot vanish... and the dependency holds a WeakRef back to its dependent, so a dependent nothing else references can be collected rather than accumulating in the source's notify list forever. Its own README lists "integration with garbage collector via WeakRef" as a headline feature, and its comparison table claims automatic garbage collection of derived reactives as a differentiator against libraries that lack it.

Objection one: weak references are heavy. memo_ is 451 bytes. The whole core with a source signal is 475. Whatever WeakRef costs, it did not cost a small library its smallness.

Objection two: weak references are slow. This is the one the numbers settle cleanly. rmemo's propagation was pathological once... quadratic in the dependent count, 156.97 ms for a 4000-dependent fanout. It is now 15.31 ms, a 10.3x improvement. The fix was bucketing the drain queue by dependency level. The weak back-edges were present in both versions. They were not the problem, and removing them would not have helped.

That is the general shape of it. Propagation cost lives in how you walk the graph. Weak references are about who is allowed to keep what alive. Conflating the two is how a useful memory strategy gets blamed for a scheduling bug.

What the non-GC languages do instead, and what it costs

lazily implements the same graph in languages with no collector, which makes the comparison concrete rather than hypothetical. Without WeakRef, the machinery you need is strictly more:

  • An arena with integer ids, so handles stay small and copyable.
  • A free list, so ids are recycled rather than growing without bound.
  • Explicit disposal, because dropping a handle reclaims nothing... handles are ids, not owners.
  • Teardown scopes, so a connection's nodes die together instead of one call per node.
  • Side tables keyed by owner for anything hung off a node, such as the edge index used for wide fan-out.

And then the hazard that falls out of all of it: a recycled id can be inherited. Dispose a node, mint a new one, and the arena hands back the same id. Any side table entry not dropped at teardown now aliases a stale index onto an unrelated node. The specification has to say this out loud:

Where the index is held outside the node... its entries have to be dropped whenever the list is cleared or the owner is torn down. A binding that recycles ids will otherwise alias a stale index onto an unrelated node.

There is a further wrinkle. Detecting a stale handle at all is only partly possible. Checking the node's kind catches a handle naming a disposed node of a different kind, but not one whose id was reused by a new node of the same kind... the classic ABA case. Closing that needs generational ids or reference-counted handles, and both cost either handle size or the copyability that wide fan-out depends on. So the family specification deliberately does not require it:

Conforming bindings therefore reject the cross-kind case and MAY admit the same-kind one. Callers must not rely on read-after-dispose failing.

That is the real price list for not having a collector. Arena, free list, explicit disposal, scopes, side tables, recycled-id invalidation, and an acknowledged hole in stale-handle detection. A weak back-edge buys all of that for free, in the languages that can have one.

That list is no longer a design position. Node disposal, teardown scopes via ctx.scope(), disarm(), and degree introspection now ship in all eight bindings. Building it produced exactly the bug class the section predicts: a use-after-free on a node destroyed while queued for recompute, a double-free from a payload destructor re-entering teardown through a reentrant mutex, and ... in the one binding where allocation failure is an ordinary return value ... eight sites that swallowed an error and produced permanent silent staleness, because a dropped dependency edge cannot be rebuilt by the recompute it just made unreachable.

The invariants that survived are the ones a test cannot pin by ordinary means, so they went to a concurrency model checker instead: disposal is atomic with respect to a concurrent read, and a recompute in flight never publishes into a node that has been disposed. Both are about a pair of pieces of state going away together, which is precisely what a sequential test cannot observe. That, too, is on the price list.

But weakness does not replace scope

The honest position is that WeakRef is necessary and not sufficient, because reachability and scope answer different questions:

Question Mechanism Timing
"Is anyone still using this?" weak back-edges, refcounted handles whenever the collector gets to it
"This work is over, free it now" explicit disposal, teardown scopes deterministic, at a point you name

A disconnect should release its nodes at the disconnect, not whenever a collector proves nothing points at them. And one stray capture in a long-lived array pins a whole subgraph no matter how weak the back-edges are... that is the classic listener leak, and weak reverse edges do not touch it, because the strong reference is on the forward path.

So the design that actually works in a GC language is both: weak back-edges so reclamation can follow reachability, and scopes so teardown can be deterministic when the program knows the answer. The family specification takes that position per binding class:

binding class scope reachability
non-GC, no destructors scopes, ended explicitly none available
non-GC, with destructors scopes, ended by the destructor refcounted handles, opt-in
tracing GC scopes, ended explicitly weak back-edges, no user-facing API

The rightmost column is the one WeakRef fills, and five of the eight bindings are in that row. Declining it there means those five carry the non-GC price list for no reason.

The argument is about layering, not purity

I am not arguing that frameworks should stop exposing an eager constructor. Ergonomics is a real goal, and the composition encodes an invariant that is easy to get wrong... the puller must actually read the slot, and it must be an effect specifically, so that it runs inside the invalidating write's flush. Get that wrong and you silently get lazy behavior with no error anywhere.

That invariant is worth a constructor. What it is not worth is a place in the kernel ... and it is not worth a noun. lazily used to spell the constructor signal(), returning a SignalHandle. It doesn't anymore. The family retired the name and the eager construction is now computed().eager(): the same two nodes, with the policy named as a verb. That is not a retreat from the ergonomic point, it is the ergonomic point taken to its conclusion ... eagerness is something you do to a computed, so the API finally says so, and the word Signal goes back to the ecosystem that was already using it for a writable source cell, where the collision started.

The distinction is open/closed. The kernel... the three node kinds and the propagation rules... should be closed to modification. Compositions should extend it from outside, using only the public primitive API, with no privileged access.

SignalHandle passes that test, and I was wrong about it initially. It adds no node variant, no arena branch, no disposal dispatch. It is two ids in a struct. I had assumed it was threading a fourth handle kind through every teardown path and argued for deleting it... then checked, and found the arena has never heard of it.

Where it failed the test was placement. signal(), get_signal(), and dispose_signal() all lived on Context, the core type. A composed thing should not need three new methods bolted onto the primitive it composes. The test to apply: could a user write their own eager wrapper with exactly the same standing as the built-in one? The built-in got a spot on Context that a user's version could not have. That was the actual violation.

The family has since resolved it, and went past the small refactor I expected here. There is no signal() on Context to privilege anymore: eager is computed().eager(), .eager() is an ordinary method on a computed handle, and dispose_signal ... the name three sections above called simply false ... is gone, replaced by .lazy(), which says what it does. Any user can write the same wrapper with exactly the standing the library has, because the library no longer has any standing the user lacks. The composition extends the kernel from outside, which is the whole of the open/closed claim, now true in the API and not only in the arena.

What this post does not yet have

An honest gap, stated rather than papered over: there is no lazy-versus-eager performance comparison here.

The experiment is sitting right there, and having written both halves of it is for once an advantage: rmemo and lazily share a primitive and disagree about propagation policy, in the same language, against benchmark shapes that map onto each other almost directly... rmemo's fanout shape is the same thing lazily's width ladder measures. Same author, same conventions, one variable deliberately changed. That comparison would say something real about what eager propagation costs and what it buys — and unlike the design agreement, a measurement is not weakened by the two systems sharing an author.

I am not running it yet, for a reason worth stating. lazily's propagation work is unfinished... per-node memory is still being characterized, and the optimization that would matter most for wide fan-out has not landed. Measuring now would compare a finished implementation against a half-finished one and report the difference as if it were about scheduling policy. That is exactly the kind of number this post is arguing against.

Worth noting what the rmemo numbers already show, though, since it bears on the WeakRef question: before level-bucketed draining, the same benchmark was quadratic in the dependent count... 156.97 ms at n=4000 against 15.31 ms after, a 10.3x improvement. The fix was propagation structure. The weak back-edges were there the whole time, in both versions. Whatever WeakRef costs, it was not what made that slow.

I will revisit the comparison when the optimization work is done.

Where this argument does not apply

Scope matters, so let me draw the boundary rather than have it drawn for me.

This is about graph-shaped reactivity. In a dataflow graph with memoization and glitch-free propagation, laziness falls out of pull plus a dirty bit, and eagerness needs an extra actor to do the pulling. That asymmetry is what makes eager the derived case.

In a stream model, the asymmetry reverses. In Rx and its descendants the producer pushes, subscription is the primitive act, and pull is the awkward special case. Someone starting from streams can reasonably say push is primitive and pull is derived. They are describing a different machine, and the argument here does not reach it.

And "primitive" is relative to a kernel, not to the universe. Cell, Slot, and Effect are primitive with respect to this graph model. Cell is itself a composition if you go down a layer. The useful question is never "what is truly irreducible" but "what is irreducible at this layer, and what is a library on top of it."

Takeaway

Signal is a memoized slot plus a puller effect. Five lines. Three node kinds in the arena, none of them Signal. Two bindings ship without it and nothing broke. The TC39 proposal decomposes into a source, a lazy memo, and a scheduling hook, with the eager thing left to userland.

Eagerness is a scheduling policy, not a data structure. It belongs in the library, not the kernel.

The lifetime half is the same shape of mistake pointed the other way. Strong back-edges are an implementation choice that got mistaken for a law, and the cost of keeping them is not mainly memory... it is that they force disposal into the public API of every binding that has a collector, and disposal APIs are where the bugs turned out to be. Weak back-edges are unobservable, so a specification can permit them without ever mentioning GC timing. The thing worth objecting to is FinalizationRegistry-driven cleanup, which is a different proposal wearing the same word.

Both halves reduce to the same question: what does the kernel actually owe you, and what is a library on top of it? Eager derivation is a library. Strong back-edges are not a requirement. Three node kinds and a propagation rule are the kernel.

And the reason all of this persisted long enough to be worth a post is that the names were doing the arguing. One word meant three different objects across three ecosystems. "Weak reference" meant two mechanisms with opposite specifiability. dispose_signal did not dispose the signal. observed_by could not observe order.

And then the family did the thing this post keeps recommending, to itself. Signal was one word standing on an architectural claim ... that eagerness is a kind of node ... and the claim was false. So the name went. The eager construction is computed().eager() now, dispose_signal became .lazy(), and the three methods that had bolted a composition onto the kernel are gone. The node arena still has three variants, none of them Signal ... the difference is that the public surface finally agrees. Treating the ambiguous name as a bug report about the architecture is not advice I am giving from outside the code; it is the last change in the series this post describes.

That is the portable lesson, and the one I would keep if you throw the rest away: when a name is ambiguous, conflicting, or simply inaccurate, treat it as a bug report about the architecture. Go read what the thing actually does. In my experience the name is rarely the only thing that is wrong.