How SynState Solved the Glitch
What Is a Glitch?
Section titled “What Is a Glitch?”In reactive programming, a glitch is a phenomenon where derived values temporarily enter inconsistent intermediate states during propagation. When a single source value changes, Observables that depend on it may update one at a time rather than all at once. If a downstream Observable depends on multiple upstream values that share a common ancestor, it can observe a state where some inputs have already updated while others have not — producing a value that should never logically exist.
A Simple Example
Section titled “A Simple Example”Consider the following Observable graph:
In SynState code:
import { collectToArray, counter, derive, map, take } from 'synstate';
const counterObservable = counter(1000 /* ms */);// 0, 1, 2, 3, ...
const multipliedBy10 = counterObservable.pipe(map((count) => count * 10));// 0, 10, 20, 30, ...
const multipliedBy1000 = counterObservable.pipe(map((count) => count * 1000));// 0, 1000, 2000, 3000, ...
const sum = derive([multipliedBy10, multipliedBy1000], (a, b) => a + b);// 0, 1010, 2020, 3030, ...
const resultPromise = collectToArray(sum.pipe(take(5)));
counterObservable.start();
const result = await resultPromise;
assert.deepStrictEqual(result, [0, 1010, 2020, 3030, 4040]);Both multipliedBy10 and multipliedBy1000 (the two inputs to derive) originate from the same source (counterObservable). Whenever counterObservable emits a new value, both inputs to derive should update together — and sum should always equal counter * 1010.
What Happens in RxJS (Glitch)
Section titled “What Happens in RxJS (Glitch)”In RxJS, combineLatest propagates a new value as soon as any one input changes. When counterObservable emits a new value, the update propagates through the graph in a depth-first manner. Since combineLatest subscribes to its inputs in order, the typical sequence when the counter changes from 0 to 1 is:
counterObservableemits1combineLatest’s subscription tomultipliedBy10(first input) fires —multipliedBy10recalculates to10, butmultipliedBy1000(second input) is still0(the old value)combineLatestemits[10, 0]→sumemits10— a glitchmultipliedBy1000(which also subscribes tocounterObservable) recalculates to1000combineLatestsees thatmultipliedBy1000changed to1000combineLatestemits[10, 1000]→sumemits1010— correct
This pattern repeats every time the counter increments:
counter: 0 1 2 3 │ │ │ │sum (RxJS): 0 10, 1010 1020, 2020 2030, 3030 ... ↑ ↑ ↑ glitch glitch glitchThe full output sequence of sum in RxJS is:
0, 10, 1010, 1020, 2020, 2030, 3030, ...The values 10, 1020, 2030, … are glitches — they represent states where one derived observable has already updated but the other has not yet recalculated. These values should never exist logically (sum should always be a multiple of 1010), yet they are emitted to subscribers, potentially causing incorrect UI rendering, invalid API calls, or subtle bugs.
Note: The order in which glitches appear depends on the order in which counterObservable notifies its subscribers. In the example above, combineLatest subscribes to multipliedBy10 first and then multipliedBy1000, so counterObservable’s subscriber list has the same order and multipliedBy10 is updated first. If multipliedBy1000 had subscribed to counterObservable before multipliedBy10, the glitch values would be 1000, 2010, 3020, … instead. Either way, the glitch occurs because the two derived observables update one at a time rather than atomically.
You can verify this behavior by running the following RxJS sample code:
import { combineLatest, interval, lastValueFrom, map, take, toArray,} from 'rxjs';
const counterObservable = interval(100);// 0, 1, 2, 3, ...
const multipliedBy10 = counterObservable.pipe(map((count) => count * 10));// 0, 10, 20, 30, ...
const multipliedBy1000 = counterObservable.pipe(map((count) => count * 1000));// 0, 1000, 2000, 3000, ...
const sum = combineLatest([multipliedBy10, multipliedBy1000]).pipe( map(([a, b]) => a + b),);// 0, 10, 1010, 1020, 2020, 2030, 3030, ...
const result = await lastValueFrom(sum.pipe(take(7), toArray()));
assert.deepStrictEqual(result, [0, 10, 1010, 1020, 2020, 2030, 3030]);Timeline
Section titled “Timeline”| Step | counterObservable | multipliedBy10 | multipliedBy1000 | sum (multipliedBy10 + multipliedBy1000) | Consistent? |
|---|---|---|---|---|---|
| 1 | 0 | 0 | 0 | 0 ✓ | Yes |
| 2 | 1 | 10 | 0 | 10 ✗ | Glitch |
| 3 | 1 | 10 | 1000 | 1010 ✓ | Yes |
| 4 | 2 | 20 | 1000 | 1020 ✗ | Glitch |
| 5 | 2 | 20 | 2000 | 2020 ✓ | Yes |
| 6 | 3 | 30 | 2000 | 2030 ✗ | Glitch |
| 7 | 3 | 30 | 3000 | 3030 ✓ | Yes |
Is This Just a Contrived Example?
Section titled “Is This Just a Contrived Example?”The counter × 10 + counter × 1000 formula above is clearly artificial, but the underlying pattern — diamond dependencies — is surprisingly common in everyday UI code. Any time two or more pieces of derived state share a common ancestor, you have a diamond.
Real-World Example: Data Table with Filters
Section titled “Real-World Example: Data Table with Filters”The interactive table below is a typical data-browsing UI: a list of records with per-column text filters (debounced) and pagination controls. Try typing in the filter inputs — every keystroke triggers a chain of derived state updates:
The three inputs to TableSliced — ItemsPerPage, TableFiltered, and CurrentPage — form multiple overlapping diamonds. For example, TableFiltered is both a direct input to TableSliced and an indirect ancestor of CurrentPage (via PageLength). Similarly, ItemsPerPage feeds into both PageLength (which influences CurrentPage) and TableSliced directly. Whenever a filter input or ItemsPerPageInput changes, several of TableSliced’s inputs share a common ancestor and must all be resolved before TableSliced can be evaluated consistently.
In a push-based library like RxJS, combineLatest would emit intermediate states where some inputs have already updated while others still hold stale values. Whether this causes a visible bug depends on the application, but the inconsistency is always there — and it scales: with inputs to combineLatest, a single source update produces emissions instead of 1, leading to the performance cliff demonstrated in the deep chain demo.
How Can We Eliminate Glitches?
Section titled “How Can We Eliminate Glitches?”The root cause of glitches is that child observables can be evaluated before all of their ancestors have finished updating. If we could guarantee that every observable is updated only after all of its ancestors have been updated, glitches would be impossible.
The Priority Queue Approach
Section titled “The Priority Queue Approach”A well-known solution is to assign each observable a level (or depth) — the length of the longest path from a root source to that observable — and use a priority queue to process updates in ascending level order.
This approach is described in detail in, for example, the paper Deprecating the Observer Pattern with Scala.React (Maier & Odersky, 2012). The algorithm works as follows:
- When a source observable changes, it does not immediately notify its children. Instead, all invalidated nodes are placed into a priority queue ordered by level.
- The propagation loop dequeues the node with the lowest level, validates (evaluates) it, and enqueues its dependent nodes (which are on greater levels) into the queue.
- This continues until the queue is empty.
Because the queue always processes lower-level nodes first, by the time a derive (or any multi-parent node) is dequeued, all of its parents are guaranteed to have already been updated in this cycle. The intermediate state — where one parent has updated but another has not — is never observed by any downstream node.
Let’s trace the priority queue approach step by step using the diamond example. Each node has a level based on its position in the graph:
counter → level 0multipliedBy10 → level 1multipliedBy1000 → level 1sum (derive) → level 2When counter changes from 0 to 1, the propagation proceeds as follows:
| Step | Action | Priority Queue (after) |
|---|---|---|
| 1 | counter updates to 1. Its dependents multipliedBy10 and multipliedBy1000 are enqueued. | [multipliedBy10 (L1), multipliedBy1000 (L1)] |
| 2 | Dequeue multipliedBy10 (level 1). Evaluate: 1 * 10 = 10. Its dependent sum is enqueued. | [multipliedBy1000 (L1), sum (L2)] |
| 3 | Dequeue multipliedBy1000 (level 1). Evaluate: 1 * 1000 = 1000. Its dependent sum is already in the queue — skip. | [sum (L2)] |
| 4 | Dequeue sum (level 2). Evaluate: 10 + 1000 = 1010. Both inputs are up-to-date. No more dependents. | [] (empty) |
The queue is empty — propagation is complete. sum emits 1010, the correct value. No glitch occurred because sum was only evaluated after both of its parents had been updated.
reactivelib/reactive is a TypeScript library that implements this topologically-ordered propagation, directly inspired by the Scala.React paper.
The Cost of Each Approach
Section titled “The Cost of Each Approach”The priority queue approach correctly eliminates glitches, but it introduces overhead on every update. Each time a source emits, every descendant must be enqueued into and dequeued from the priority queue — each operation costing where is the number of observables in the graph. For a graph with descendants, the total propagation cost per source emission becomes .
RxJS’s depth-first propagation avoids this overhead — each node is visited with just a function call. However, RxJS treats the dependency graph as a tree, not a DAG (directed acyclic graph). When a diamond dependency exists, RxJS traverses both branches independently: the shared descendant (e.g., combine) is evaluated once for each branch that reaches it.
The cost depends on the diamond topology:
- Wide diamond — a fan-out of branches merging back into a single
combineLatest. Each source emission triggerscombineLatestevaluations, each processing inputs → per source emission. - Cascaded diamonds — a chain of diamonds in series, where each
combineLatest’s output feeds into the next diamond. If each diamond has a fan-out of (i.e., branches merging into onecombineLatest), each level multiplies the number of emissions by : level 1 fires times, level 2 fires times, …, level fires times → per source emission. For a binary fan-out (), this becomes .
| Approach | No diamond (tree) | With diamond (worst case) |
|---|---|---|
| RxJS (depth-first) | ||
| Priority queue (Scala.React) |
( = fan-out per diamond stage)
For graphs without diamond dependencies, RxJS is faster by a factor. But once diamonds are present, RxJS’s cost grows exponentially in the worst case (cascaded diamonds), while the priority queue approach remains . Neither achieves the ideal of glitch-free propagation.
This matters in performance-sensitive scenarios such as mousemove or pointermove events, animation frames, or any high-frequency stream where updates fire dozens or hundreds of times per second. The repeated heap operations of the priority queue approach add up, and RxJS’s redundant evaluations in diamond graphs scale even worse.
SynState solves both problems: it achieves glitch-free propagation in — the same asymptotic cost as RxJS’s tree traversal, but without glitches and without the exponential worst case ( = fan-out per diamond). The deep dependency chain demo shows the redundant emissions on a wide diamond, where outputs meet in one combineLatest: for each mouse move, RxJS’s Total updates counter goes up by and SynState’s by one, and each of RxJS’s updates redraws all points.
What Happens in SynState (Glitch-Free, O(n))
Section titled “What Happens in SynState (Glitch-Free, O(n))”SynState’s key insight is that a node’s place in the update order is known when the node is built: its depth, a small integer. The queue of the priority queue approach can then be one bucket per depth, where putting a node in and taking it out each cost instead of .
A queue bucketed by depth
Section titled “A queue bucketed by depth”When an observable is created via .pipe() or a combinator (combine, derive, merge, zip), SynState computes its depth — the length of the longest path from any root source:
- Root observables:
depth = 0. - Child observables:
depth = 1 + max(parent depths), raised to the scope’s floor when the child is created in a scope made withcreateScope(parent, { after }), so that it updates afterafter.
An update starts at a manager observable (see below), which opens a queue for the update with one bucket per depth below it. Every node that emits in the update, the manager first, puts each of its children into the bucket of the child’s depth, once per update however many of the child’s parents emit. The manager then takes the buckets in ascending depth and calls tryUpdate on each node in them; a node that emits puts its own children into deeper buckets as it goes, and the update ends when the last bucket is empty:
// Simplified from ManagerObservableClassprotected startUpdate(nextValue: A): void { // No child and no subscriber: store the value, open no update if (this.setNextIfUnobserved(nextValue)) return;
// The token of the update is also its queue const updateToken = openUpdate(this.depth);
this.setNext(nextValue, updateToken); // queues this node's children
drainUpdate(updateToken); // visits the buckets in ascending depth}
// Simplified from ObservableBaseClass, which every observable extendsprotected setNext(nextValue: A, updateToken: UpdateToken): void { this.#updateToken = updateToken;
this.#snapshot = Optional.some(nextValue);
// Into the bucket of each child's depth, once per update enqueueChildren(this.#children, updateToken);
// ...then notify the subscribers}This is a key architectural difference from RxJS. In RxJS, each observable propagates to its children as it emits, depth first. In SynState, an emitting node only queues its children; the manager observable that started the update decides when each of them is visited, in a single loop. This central loop is what lets the queue order the nodes by depth.
SynState’s Observable types are RootObservable, SyncChildObservable, and AsyncChildObservable. Of these, RootObservable and AsyncChildObservable act as managers. A RootObservable is the entry point of a graph, while an AsyncChildObservable is created by time-shifting operators such as debounce — since their emissions are asynchronous and cannot be coordinated by the upstream root, they take over as managers for their own downstream subgraph.
A parent is always shallower than its child and the buckets are processed shallowest first, so by the time a node is processed, all of its parents are final — the same guarantee as the priority queue approach. Each node the update reaches is put in a bucket once and taken out once, both in , so an update costs in the nodes it reaches, and the nodes it does not reach, such as those below a filter that rejected the value, are never touched. The buckets are one array per depth, reused from one update to the next: emptying one is setting its length back to 0, so an update allocates nothing but its token. A write to a node with no child and no subscriber, such as a branch no switchMap follows, does not even open an update: there is no one to deliver it to, so it stores the value with a token of its own and returns.
The animation steps through one update of the diamond. A chip is a node waiting in a bucket: D is queued by B, and not again by C.
Applying this to the diamond example:
depth 0: counter ← source emitsdepth 1: multipliedBy10 ← updated first (depth 1)depth 1: multipliedBy1000 ← updated next (depth 1)depth 2: sum (derive) ← updated last (depth 2); both inputs are up-to-dateThe output of sum in SynState is:
0, 1010, 2020, 3030, 4040, ...No intermediate states. No glitches. Every value emitted to subscribers is consistent.
Timeline
Section titled “Timeline”| Step | counterObservable | multipliedBy10 | multipliedBy1000 | sum (multipliedBy10 + multipliedBy1000) | Consistent? |
|---|---|---|---|---|---|
| 1 | 0 | 0 | 0 | 0 ✓ | Yes |
| 2 | 1 | 10 | 1000 | 1010 ✓ | Yes |
| 3 | 2 | 20 | 2000 | 2020 ✓ | Yes |
| 4 | 3 | 30 | 3000 | 3030 ✓ | Yes |
Handling skipped updates: the update token
Section titled “Handling skipped updates: the update token”Visiting by depth eliminates glitches when every node updates. But not every node updates on every cycle — operators like filter and skipIfNoChange can skip an emission. This introduces a subtlety: how does a downstream node know whether its parent was intentionally skipped in the current cycle, or simply hasn’t been reached yet?
Consider the following graph where D applies a filter:
const A = counter(1000);// 0, 1, 2, 3, 4, ...
const B = A.pipe(map((n) => n * 10));// 0, 10, 20, 30, 40, ...
const D = A.pipe(filter((n) => n % 2 === 0));// 0, 2, 4, ... (odd values are skipped)
const C = derive([B, D], (b, d) => b + d);// sum of B and D: 0, 10, 22, 32, 44, 54, 66, ...
const E = D.pipe(map((n) => n * 10));// 0, 20, 40, ...
const resultC = collectToArray(C.pipe(take(7)));
const resultE = collectToArray(E.pipe(take(4)));When A emits an even value (e.g., 2), all nodes update: B becomes 20, D passes 2, C emits 22, and E becomes 20. But when A emits an odd value (e.g., 1), D does not pass the predicate — so D and its descendant E should not update, while B does update to 10 and C should re-evaluate because B has changed. C is queued as soon as B emits, so when it is visited it must be able to determine on its own which of its parents were updated in this cycle and which were skipped.
SynState solves this by giving every update cycle a unique identity — an update token. The token is a small object created at the start of each cycle by openUpdate(), and the same object holds the cycle’s queue. (A write that nothing observes opens no cycle; it is given an empty token of its own, so that its updateToken still changes.) The token flows through the graph as follows:
- The manager creates a fresh token and stamps itself via
setNext(value, token). - The same token is passed to the
tryUpdate(token)of every node taken out of the queue. - When a child successfully updates, it calls
setNext(newValue, token), stamping itself with the current cycle’s token. - When a child skips (e.g.,
filter’s predicate returnsfalse), it does not callsetNext— so itsupdateTokenremains the token from a previous cycle, and its children are not queued.
This creates a simple invariant: a node’s updateToken equals the current cycle’s token if and only if it was updated in this cycle. Each node checks this on its parents with a single reference comparison — — to decide whether to update or skip:
- Single-parent operators (e.g.,
map,scan): Checkparent.updateToken === token. If the parent’s token doesn’t match, the parent was skipped — so the child skips too. - Multi-parent operators (e.g.,
combine): Check whether at least one parent’s token matches. If all parents were skipped,combineskips. If at least one updated,combinere-evaluates using the latest snapshot from every parent (whether updated in this cycle or not).
Each operator is a subclass of SyncChildObservableClass or AsyncChildObservableClass, and overrides tryUpdate, which the manager calls with the token:
// Simplified from DeriveObservableClass, which `combine` is built onoverride tryUpdate(updateToken: UpdateToken): void { let updated = false;
for (const [i, parent] of this.parents.entries()) { this.values[i] = parent.getSnapshot().value;
if (parent.updateToken === updateToken) { updated = true; } }
// Skip only if ALL parents were skipped in this cycle if (!updated) { return; }
// ... this.setNext(this.project(...this.values), updateToken) ...}// Simplified from FilterObservableClassoverride tryUpdate(updateToken: UpdateToken): void { const par = this.parents[0];
if (par.updateToken !== updateToken) { return; // parent was skipped → skip this node too }
const sn = par.getSnapshot();
if (Optional.isSome(sn) && this.#predicate(sn.value)) { this.setNext(sn.value, updateToken); // stamp with token → "I updated" } // If predicate fails: no setNext → updateToken stays stale → descendants know to skip}Because each node’s skip-or-update decision is a constant-time token comparison, the overall cost of a single update cycle remains where is the number of nodes the update reaches — and each node pays amortized per cycle.
The animation runs the graph above for A = 1, then for A = 2. For the odd value D does not emit, so E is never queued, while C is queued by B and learns from the tokens that only B emitted.
Summary
Section titled “Summary”| Approach | No diamond (tree) | With diamond (worst case) | Glitch-free? |
|---|---|---|---|
| RxJS (depth-first) | No | ||
| Priority queue (Scala.React) | Yes | ||
| SynState | Yes |
SynState achieves glitch-free propagation in by exploiting two properties of the dependency graph:
- Integer depths — a node’s depth is fixed when it is constructed, so the queue can be one bucket per depth, with insertion and removal instead of a heap’s .
- Update tokens — a unique token per cycle lets each node determine in whether its parents participated in the current cycle, enabling correct skip-or-update decisions for a node queued by one parent while another was skipped.
Cycle detection
Section titled “Cycle detection”SynState’s propagation model assumes the dependency graph is a DAG (directed acyclic graph). The public API guarantees this by construction: a new observable’s parents exist before it and cannot change afterwards, so no observable can become its own ancestor. On top of that, SynState verifies it with a cycle check at construction time in development — whenever a new observable is created via .pipe() or a combinator (combine, derive, merge, zip), a depth-first traversal of the ancestor chain verifies that no node appears as its own ancestor. If a cycle is detected, an error is thrown immediately:
Circular dependency detected in observable graph:a child observable cannot be its own ancestor.The check is on in development and off in production. It walks every ancestor of each new observable, so every creation pays for it, and production does not: it is off when process.env.NODE_ENV is 'production', and wherever there is no process, as in a browser bundle. Since the public API cannot build a cycle, what the check catches is code that rewrites an observable’s parents, and such code runs in development and tests before it reaches production.
This means that once the graph has been built, all subsequent update cycles are guaranteed to operate on a DAG — no infinite loops, and a depth for every node.
Other libraries handle cycles differently:
- RxJS does not perform cycle detection. In practice,
pipe()andcombineLatest()follow a forward-only construction pattern — you can only reference Observables that already exist — so cycles rarely arise in normal usage. However, RxJS provides no explicit validation as a safety net, unlike SynState’s construction-time DFS check. - Jotai does not detect cycles. Circular
atomdefinitions (e.g.,atomAreadsatomB,atomBreadsatomA) are silently accepted at definition time. The cycle only manifests when the atom is read, crashing withMaximum call stack size exceeded— a stack overflow rather than a clear diagnostic.
These behaviors are verified in the circular dependency comparison tests.