<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>OIMDB Blog</title>
        <link>https://oimdb.org/blog</link>
        <description>OIMDB Blog</description>
        <lastBuildDate>Fri, 24 Jul 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <item>
            <title><![CDATA[An update during the batch is a consequence, not an input]]></title>
            <link>https://oimdb.org/blog/mid-batch-updates</link>
            <guid>https://oimdb.org/blog/mid-batch-updates</guid>
            <pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Many reactive systems drain a queue and let handlers enqueue more updates into the same drain, processed right there. But an update that only appears because the batch is running is never new input — it's a consequence, and consequences are derivations. There is no such thing as new source truth in the middle of a batch.]]></description>
            <content:encoded><![CDATA[<p>Many reactive systems drain a queue and let handlers enqueue more updates into the same drain, processed right there. But an update that only appears <em>because</em> the batch is running is never new input — it's a consequence, and consequences are derivations. There is no such thing as new source truth in the middle of a batch.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="context">Context<a href="https://oimdb.org/blog/mid-batch-updates#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>A flush takes the source mutations that accumulated since the last flush and settles everything that depends on them. Sources are the inputs — a user typed, a request resolved, code called <code>upsert</code>. Anything that happens <em>while</em> the flush runs is downstream of those inputs.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="problem">Problem<a href="https://oimdb.org/blog/mid-batch-updates#problem" class="hash-link" aria-label="Direct link to Problem" title="Direct link to Problem" translate="no">​</a></h2>
<p>The tempting shortcut is to reprocess whatever gets enqueued mid-flush in the same flush. It rests on a category error: that a value written during the batch might be new input. It never is. Walk the cases:</p>
<ul>
<li class=""><strong>"When X changes, set Y."</strong> <code>Y</code> is a function of <code>X</code> — that is denormalization, and the thing that turns one value into another is a computed. Written as an imperative effect, it's a computed you haven't named. Re-drained in place, it thrashes (recomputes once per source-mutation) and can run on a half-updated graph — a glitch (<a class="" href="https://oimdb.org/blog/glitch-free-batched-compute">why computed values wait for the flush</a>).</li>
<li class=""><strong>An async result.</strong> It isn't in this batch at all — the response lands later, as its own event, starting its own flush. The effect in <em>this</em> batch only kicks off the I/O; the write happens in a future round.</li>
<li class=""><strong>The current time, a random value.</strong> These are genuinely new information — so they are <em>inputs</em>. If <code>Y = X × now()</code>, you have two inputs, <code>X</code> and <code>now</code>, and <code>Y</code> is a computed of both. You model the clock as a source, not as an imperative write buried in an effect.</li>
</ul>
<p>Every route ends in the same place: what arises during the batch is a derivation. New input always arrives as its own event, in its own batch.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="options">Options<a href="https://oimdb.org/blog/mid-batch-updates#options" class="hash-link" aria-label="Direct link to Options" title="Direct link to Options" translate="no">​</a></h2>
<ul>
<li class=""><strong>Re-drain mid-flush enqueues in place.</strong> Treats derivations as inputs — thrash, glitches, and writes that feed themselves with no bounded end.</li>
<li class=""><strong>Recompute derivations eagerly, per mutation.</strong> Redundant recomputes and glitches on every shared descendant.</li>
<li class=""><strong>Settle derivations once, after the sources are fixed, in dependency order.</strong> Each derived value recomputes once; new source truth only ever comes from the next batch.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="what-oimdb-does">What OIMDB does<a href="https://oimdb.org/blog/mid-batch-updates#what-oimdb-does" class="hash-link" aria-label="Direct link to What OIMDB does" title="Direct link to What OIMDB does" translate="no">​</a></h2>
<p>The third. The <strong>compute runtime</strong> recomputes dirty computeds <strong>once each</strong>, parents before children, by <a class="" href="https://oimdb.org/blog/precomputed-levels">graph level</a>, at the flush boundary. A chain <code>A → B → C</code> collapses in one ordered pass; a value with three dirty inputs recomputes once.</p>
<p>And the <strong>queue</strong> defends the boundary. If code <em>does</em> write a source inside an effect — a computed that wasn't named — a <a class="" href="https://oimdb.org/blog/queue-double-buffer">double-buffered swap</a> sends that write's notification to the <em>next</em> flush, not this one. The mismodeled write can't cascade in place; the current flush stays bounded. The fix is still to make it a computed, but the queue won't let it melt down in the meantime.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="cost">Cost<a href="https://oimdb.org/blog/mid-batch-updates#cost" class="hash-link" aria-label="Direct link to Cost" title="Direct link to Cost" translate="no">​</a></h2>
<p>One mental model to hold: the batch settles at the flush boundary, not at the instant of each write. A source you write in an effect is in the store immediately but doesn't notify until the next flush, so a subscriber won't have reacted synchronously. And the tell is simple — if you're writing a source inside an effect, you've found a computed you haven't named yet.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="where-it-lives">Where it lives<a href="https://oimdb.org/blog/mid-batch-updates#where-it-lives" class="hash-link" aria-label="Direct link to Where it lives" title="Direct link to Where it lives" translate="no">​</a></h2>
<ul>
<li class=""><code>packages/core/src/modules/compute/core/OIMComputeRuntime.ts</code>, <code>.../computed/core/OIMComputed.ts</code> — level-ordered, recompute-once-per-flush derivation.</li>
<li class=""><code>packages/core/src/core/OIMEventQueue.ts</code> — double-buffered drain; a source write during a flush defers to the next.</li>
</ul>]]></content:encoded>
            <category>reactivity</category>
            <category>internals</category>
        </item>
        <item>
            <title><![CDATA[The update order is a property of the graph, not the update]]></title>
            <link>https://oimdb.org/blog/precomputed-levels</link>
            <guid>https://oimdb.org/blog/precomputed-levels</guid>
            <pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[A reactive graph must run parents before children. The real question is when you compute that order. Systems with dynamic dependencies rediscover it on every write. If dependencies are fixed up front, the order is a property of the graph — computed once, then free forever.]]></description>
            <content:encoded><![CDATA[<p>A reactive graph must run parents before children. The real question is <em>when you compute that order</em>. Systems with dynamic dependencies rediscover it on every write. If dependencies are fixed up front, the order is a property of the graph — computed once, then free forever.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="context">Context<a href="https://oimdb.org/blog/precomputed-levels#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>An <code>OIMComputed</code> or <code>OIMEffect</code> declares its dependencies explicitly — <code>deps: [...]</code> — fixed at construction and never changing. That single constraint is what the rest of this rests on.</p>
<p>The alternative is autotracking (Jotai, Solid, MobX): a node's dependencies are discovered by <em>running</em> its compute and recording which sources it read. Deps aren't known until the node runs, and they can differ run to run (<code>cond ? get(a) : get(b)</code>).</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="problem">Problem<a href="https://oimdb.org/blog/precomputed-levels#problem" class="hash-link" aria-label="Direct link to Problem" title="Direct link to Problem" translate="no">​</a></h2>
<p>To avoid glitches and redundant recomputes (<a class="" href="https://oimdb.org/blog/glitch-free-batched-compute">why computed values wait for the flush</a>), the runtime must recompute dirty nodes parents-first — in topological order. Someone has to produce that order.</p>
<p>With dynamic deps you <em>can't</em> know it ahead of time, and it can change, so you rebuild it on every propagation: collect the dirty dependents, count in-degrees within that subgraph, drain the zero-in-degree ones first (Kahn's algorithm). That is a topological sort <strong>per write</strong>, scaling with the affected subgraph.</p>
<p>With static deps the edges never move — so the order never moves either.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="options">Options<a href="https://oimdb.org/blog/precomputed-levels#options" class="hash-link" aria-label="Direct link to Options" title="Direct link to Options" translate="no">​</a></h2>
<p>The axis is <em>where the ordering work lives</em>.</p>
<ul>
<li class=""><strong>Dynamic deps, sort per write.</strong> Maximum flexibility — conditional dependencies, edges that appear and vanish. Pays a topological sort on every propagation, and can precompute nothing.</li>
<li class=""><strong>Static deps, recompute the order each flush.</strong> Throwing away the fact that edges are fixed — the same per-flush sort cost for no reason.</li>
<li class=""><strong>Static deps, precompute levels once.</strong> A node's <code>level</code> = one above the max of its dependencies' levels, derived at construction. The runtime never sorts: it buckets scheduled nodes by level and drains <code>0 → N</code>. Ordering cost at flush time is zero; the price is that deps can't change at runtime.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="what-oimdb-does">What OIMDB does<a href="https://oimdb.org/blog/precomputed-levels#what-oimdb-does" class="hash-link" aria-label="Direct link to What OIMDB does" title="Direct link to What OIMDB does" translate="no">​</a></h2>
<p>The third. When a computed is built, the engine derives its level from its dependencies — which were built earlier, so their levels already exist — and stores it. Levels live in the runtime: the engine owns them, nodes don't carry them.</p>
<p>At a flush, an invalidated node schedules itself <em>at its level</em> into an <strong>array indexed by level</strong> (levels are dense small integers — direct index, no hashing, unlike a map), and the runtime drains ascending. The per-level buckets are pooled <code>Set</code>s, cleared between flushes rather than reallocated. The hot path is an array index and a drain — no sort, no per-node lookup, no allocation.</p>
<p>Two things fall out of static levels that a per-write approach doesn't get for free:</p>
<ul>
<li class="">
<p><strong>The deep-uneven glitch disappears.</strong> A node that depends on both a shallow ancestor <code>A</code> and a deep one <code>D</code> (with <code>D</code> several levels below <code>A</code>) is scheduled the instant <code>A</code> fires — but it lands in <em>its own</em> high bucket and waits until <code>D</code>'s level has drained. It reads <code>D</code> fresh, exactly once. Without levels it would run early in <code>A</code>'s wave and read a stale <code>D</code> — a glitch that lazy pull-on-read doesn't catch, because <code>D</code> isn't even marked dirty yet.</p>
</li>
<li class="">
<p><strong>Turning the levels on was nearly free at runtime.</strong> Flipping the scheduler from "everything at level 0, let lazy pulls sort out the order" to "each node at its real depth" removed the redundant reschedules and out-of-order pulls; the array-by-level removed the map hashing. On the compute benchmarks that was roughly <strong>+78%</strong> on a diamond graph and <strong>+24%</strong> on a depth-50 chain — for a change that only <em>moved</em> work to construction time.</p>
</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="cost">Cost<a href="https://oimdb.org/blog/precomputed-levels#cost" class="hash-link" aria-label="Direct link to Cost" title="Direct link to Cost" translate="no">​</a></h2>
<p>Dependencies are fixed. No <code>get(cond) ? get(a) : get(b)</code> — a node can't grow or drop a dependency at runtime. If a value genuinely depends on different sources at different times, you declare the union and branch inside <code>compute</code>, or rebuild the node. You trade the flexibility of autotracking for a runtime with no per-update ordering work and a deterministic pass — and levels you compute once instead of re-deriving on every write.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="where-it-lives">Where it lives<a href="https://oimdb.org/blog/precomputed-levels#where-it-lives" class="hash-link" aria-label="Direct link to Where it lives" title="Direct link to Where it lives" translate="no">​</a></h2>
<ul>
<li class=""><code>packages/core/src/modules/compute/core/OIMComputeRuntime.ts</code> — <code>computeLevel</code> / <code>registerLevel</code> / <code>getLevel</code>, and the array-by-level drain with pooled sets.</li>
<li class=""><code>packages/core/src/modules/computed/core/OIMComputed.ts</code>, <code>.../effect/core/OIMEffect.ts</code> — declare deps; level derived from the runtime at construction.</li>
<li class=""><code>packages/core/src/dx/on.ts</code>, <code>.../dx/OIMCollectionKit.ts</code> — <code>on.*</code> + <code>kit.computed</code> / <code>kit.effect</code> build the static graph without hand-wiring dependencies.</li>
</ul>]]></content:encoded>
            <category>reactivity</category>
            <category>performance</category>
        </item>
        <item>
            <title><![CDATA[A boolean flag beats a Set for the dirty batch]]></title>
            <link>https://oimdb.org/blog/dirty-flag-vs-set</link>
            <guid>https://oimdb.org/blog/dirty-flag-vs-set</guid>
            <pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[A keyed emitter dedups its per-flush batch. The default tool is a Set. A boolean flag on the carrier plus a plain array does the same job ~4× cheaper, paid for with a hand-maintained invariant.]]></description>
            <content:encoded><![CDATA[<p>A keyed emitter dedups its per-flush batch. The default tool is a <code>Set</code>. A boolean flag on the carrier plus a plain array does the same job ~4× cheaper, paid for with a hand-maintained invariant.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="context">Context<a href="https://oimdb.org/blog/dirty-flag-vs-set#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p><code>OIMCarrierKeyedEmitter</code> is the per-key pub/sub behind collections and indexes. Handlers live on the <em>carrier</em> — the collection's entity slot, or the index's bucket (see <a class="" href="https://oimdb.org/blog/handlers-on-carrier">handlers on the carrier</a>). On a write the changed carrier is marked dirty; on <code>queue.flush()</code> the batch of dirty carriers is delivered once.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="problem">Problem<a href="https://oimdb.org/blog/dirty-flag-vs-set#problem" class="hash-link" aria-label="Direct link to Problem" title="Direct link to Problem" translate="no">​</a></h2>
<p>Marking a carrier dirty runs on <strong>every mutation</strong> — the hottest write-path op, and far more frequent than flushes. The batch has to dedup: a carrier written ten times in one tick must be delivered once.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="options">Options<a href="https://oimdb.org/blog/dirty-flag-vs-set#options" class="hash-link" aria-label="Direct link to Options" title="Direct link to Options" translate="no">​</a></h2>
<ul>
<li class=""><strong><code>Set&lt;carrier&gt;</code></strong> — <code>set.add(carrier)</code> computes the carrier's identity hash, probes the set's table, inserts. Membership and dedup come for free.</li>
<li class=""><strong>flag + array</strong> — <code>if (carrier.dirty) return; carrier.dirty = true; arr.push(carrier)</code>. Dedup is a boolean read, membership lives on the carrier itself, append is an array push.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="what-oimdb-does">What OIMDB does<a href="https://oimdb.org/blog/dirty-flag-vs-set#what-oimdb-does" class="hash-link" aria-label="Direct link to What OIMDB does" title="Direct link to What OIMDB does" translate="no">​</a></h2>
<p>Flag + array:</p>
<div class="language-ts codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_t_Hd"><pre tabindex="0" class="prism-code language-ts codeBlock_RMoD thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">carrier</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">dirty</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">return</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain">        </span><span class="token comment" style="color:#999988;font-style:italic">// already in the batch</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">carrier</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">dirty </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">true</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">this</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">dirtyCarriers</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">push</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">carrier</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<p>The flag is reset as each carrier is delivered, leaving it re-markable next tick.</p>
<p>Microbench — 1000 carriers, marked + iterated over 20,000 flushes (20M mark cycles), reused containers:</p>
<table><thead><tr><th>pending batch</th><th>all dirty</th><th>5% dirty per flush</th></tr></thead><tbody><tr><td><code>Set&lt;carrier&gt;</code></td><td>778 ms</td><td>43 ms</td></tr><tr><td>flag + array</td><td>181 ms</td><td>9 ms</td></tr></tbody></table>
<p>~4.3× (all dirty), ~4.8× (sparse). The gap is the identity-hash that <code>Set.add</code> runs on every mark and the array push skips.</p>
<p>This measures the batching primitive in isolation — mark + iterate, nothing else. A real write does more around it (apply the patch, schedule the flush), so the dirty-batch op is a <em>fraction</em> of one write, not 4× of end-to-end write time. The point is that it's a fraction paid on every mutation while flushes are rare, so shaving the per-mark constant compounds.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="cost">Cost<a href="https://oimdb.org/blog/dirty-flag-vs-set#cost" class="hash-link" aria-label="Direct link to Cost" title="Direct link to Cost" translate="no">​</a></h2>
<p>The flag is shared mutable state with an invariant: <code>carrier.dirty === true</code> iff the carrier is in <code>dirtyCarriers</code>. Break it and you either drop updates (flag stuck <code>true</code> → future marks skipped) or double-deliver. A <code>Set</code> needs none of this — removal from the batch is implicit.</p>
<p>Holding the invariant when a handler throws mid-flush takes explicit cleanup: delivery resets the flags of any carriers it never reached.</p>
<div class="language-ts codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_t_Hd"><pre tabindex="0" class="prism-code language-ts codeBlock_RMoD thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#393A34"><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">finally</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> i </span><span class="token operator" style="color:#393A34">&lt;</span><span class="token plain"> flushing</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">length</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"> i</span><span class="token operator" style="color:#393A34">++</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> flushing</span><span class="token punctuation" style="color:#393A34">[</span><span class="token plain">i</span><span class="token punctuation" style="color:#393A34">]</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">dirty </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">false</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="where-it-lives">Where it lives<a href="https://oimdb.org/blog/dirty-flag-vs-set#where-it-lives" class="hash-link" aria-label="Direct link to Where it lives" title="Direct link to Where it lives" translate="no">​</a></h2>
<p><code>packages/core/src/core/OIMCarrierKeyedEmitter.ts</code></p>]]></content:encoded>
            <category>internals</category>
            <category>performance</category>
        </item>
        <item>
            <title><![CDATA[A cancellable enqueue without per-call allocation]]></title>
            <link>https://oimdb.org/blog/enqueue-without-closures</link>
            <guid>https://oimdb.org/blog/enqueue-without-closures</guid>
            <pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[A task queue that lets callers cancel a queued task usually hands back a disposer closure — one allocation on every enqueue. When enqueue is on the hot path, storing the function and cancelling by reference avoids the tax entirely.]]></description>
            <content:encoded><![CDATA[<p>A task queue that lets callers cancel a queued task usually hands back a disposer closure — one allocation on every enqueue. When enqueue is on the hot path, storing the function and cancelling by reference avoids the tax entirely.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="context">Context<a href="https://oimdb.org/blog/enqueue-without-closures#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p><code>OIMEventQueue</code> batches one-shot tasks: emitters and the store enqueue a flush task, and <code>flush()</code> runs the batch. Every reactive write enqueues, so <code>enqueue</code> is a hot-path op — and tasks must be cancellable, because an emitter torn down before flush has to drop its pending task.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="problem">Problem<a href="https://oimdb.org/blog/enqueue-without-closures#problem" class="hash-link" aria-label="Direct link to Problem" title="Direct link to Problem" translate="no">​</a></h2>
<p>A cancellable enqueue has to give the caller some way to cancel. The usual shapes allocate. Returning a disposer closure is one function object per call; wrapping the task so the queue can flip a "cancelled" flag on it is another — plus the GC of discarding them when the task runs. Most callers never cancel, so on a hot path that allocation is mostly waste.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="options">Options<a href="https://oimdb.org/blog/enqueue-without-closures#options" class="hash-link" aria-label="Direct link to Options" title="Direct link to Options" translate="no">​</a></h2>
<ul>
<li class=""><strong>Return a disposer closure (<code>enqueue(fn): () =&gt; void</code>)</strong> — ergonomic, callers hold one handle. Allocates a closure per call whether or not anyone cancels.</li>
<li class=""><strong>Wrap the task to carry cancel state</strong> — store <code>{ fn, cancelled }</code> instead of <code>fn</code>; cancel flips <code>cancelled</code> and the drain skips it, so cancel never has to locate and <code>delete</code> an entry. But the wrapper is an allocation per enqueue (the very tax this is trying to avoid), and the stored object is no longer the caller's <code>fn</code>, so identity-based dedup is lost.</li>
<li class=""><strong>Store the function reference, cancel by reference</strong> — <code>enqueue(fn)</code> adds <code>fn</code> to a <code>Set</code>; <code>cancel(fn)</code> deletes it. No wrapper, no returned closure; dedup falls out of <code>Set</code> identity. The caller must keep the reference to cancel.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="what-oimdb-does">What OIMDB does<a href="https://oimdb.org/blog/enqueue-without-closures#what-oimdb-does" class="hash-link" aria-label="Direct link to What OIMDB does" title="Direct link to What OIMDB does" translate="no">​</a></h2>
<p>Store the reference, cancel by reference:</p>
<div class="language-ts codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_t_Hd"><pre tabindex="0" class="prism-code language-ts codeBlock_RMoD thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">public</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">enqueue</span><span class="token punctuation" style="color:#393A34">(</span><span class="token function-variable function" style="color:#d73a49">fn</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=&gt;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">void</span><span class="token punctuation" style="color:#393A34">)</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">void</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">this</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">tasks</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">add</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">fn</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">this</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">ensureScheduled</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">public</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">cancel</span><span class="token punctuation" style="color:#393A34">(</span><span class="token function-variable function" style="color:#d73a49">fn</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=&gt;</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">void</span><span class="token punctuation" style="color:#393A34">)</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">void</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">this</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">tasks</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">delete</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">fn</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">this</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">flushing</span><span class="token operator" style="color:#393A34">?.</span><span class="token function" style="color:#d73a49">delete</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">fn</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>Zero allocation per enqueue, and idempotent — the same <code>fn</code> enqueued twice runs once, because the <code>Set</code> keys on identity. Each emitter holds one stable <code>onFlush</code> method and enqueues that, so its scheduling dedup is just the <code>Set</code>.</p>
<p>The disposer-closure shape against the by-reference one (3M iterations, no-op task — so this isolates queue overhead, not task work):</p>
<table><thead><tr><th>approach</th><th>enqueue + cancel</th><th>enqueue + flush</th></tr></thead><tbody><tr><td>disposer closure</td><td>0.116 µs</td><td>0.135 µs</td></tr><tr><td>by reference</td><td>0.067 µs</td><td>0.067 µs</td></tr></tbody></table>
<p>The gap (~0.05–0.07 µs) is the closure the call no longer allocates. By-reference reads the same for cancel and flush because the task is a no-op: at this resolution the closure alloc is the only thing that moves, so removing it flattens both columns to the bare <code>Set</code> op.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="cost">Cost<a href="https://oimdb.org/blog/enqueue-without-closures#cost" class="hash-link" aria-label="Direct link to Cost" title="Direct link to Cost" translate="no">​</a></h2>
<p>Cancellation needs the caller to hold the original reference and pass the same one back — there is no opaque handle to lean on. Callers that enqueue a stable bound method already have it; it is only a sharper edge for code that wanted a disposer to stash.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="where-it-lives">Where it lives<a href="https://oimdb.org/blog/enqueue-without-closures#where-it-lives" class="hash-link" aria-label="Direct link to Where it lives" title="Direct link to Where it lives" translate="no">​</a></h2>
<p><code>packages/core/src/core/OIMEventQueue.ts</code></p>]]></content:encoded>
            <category>internals</category>
            <category>performance</category>
        </item>
        <item>
            <title><![CDATA[Why computed values wait for the flush]]></title>
            <link>https://oimdb.org/blog/glitch-free-batched-compute</link>
            <guid>https://oimdb.org/blog/glitch-free-batched-compute</guid>
            <pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[If A feeds both B and C, and D reads B and C, then changing A should recompute D exactly once with consistent inputs. The naive wiring recomputes D twice and lets it see one fresh and one stale parent in between — a glitch.]]></description>
            <content:encoded><![CDATA[<p>If <code>A</code> feeds both <code>B</code> and <code>C</code>, and <code>D</code> reads <code>B</code> and <code>C</code>, then changing <code>A</code> should recompute <code>D</code> exactly once with consistent inputs. The naive wiring recomputes <code>D</code> twice and lets it see one fresh and one stale parent in between — a glitch.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="context">Context<a href="https://oimdb.org/blog/glitch-free-batched-compute#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p><code>OIMComputed</code> nodes and effects form a dependency graph driven by <code>OIMComputeRuntime</code>. A write to a source marks its dependents dirty; the runtime is what decides when and in what order they recompute.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="problem">Problem<a href="https://oimdb.org/blog/glitch-free-batched-compute#problem" class="hash-link" aria-label="Direct link to Problem" title="Direct link to Problem" translate="no">​</a></h2>
<p>The classic diamond. A synchronous, push-on-write graph propagates a change to <code>A</code>'s dependents in stored order: <code>A → B → D</code>, then <code>A → C → D</code>. The first path recomputes <code>D</code> while <code>C</code> still holds its old value. <code>D</code> runs twice, and its first run produces a value computed from a half-updated graph — one that never should have existed. Eager subscribers see that ghost. The deeper the graph, the more shared descendants multiply both the redundant work and the inconsistent snapshots flowing downstream.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="options">Options<a href="https://oimdb.org/blog/glitch-free-batched-compute#options" class="hash-link" aria-label="Direct link to Options" title="Direct link to Options" translate="no">​</a></h2>
<p>Judge each on the diamond's two failure modes — glitches (a node runs on a stale parent) and redundant recompute — plus how subscribers get told to run.</p>
<ul>
<li class=""><strong>Synchronous push</strong> — recompute a node the instant any input changes. Hits <em>both</em> failures: <code>D</code> recomputes once per path (twice) and emits the half-updated value in between. Subscribers fire eagerly, so they see the glitch too. Cheapest to build, wrong on every shared descendant.</li>
<li class=""><strong>Mark-dirty + pull on read</strong> — invalidate eagerly, recompute lazily when something reads. Removes the redundant recompute and the glitch <em>for code that pulls</em>. But effects and subscribers don't pull — they must be told when to run; pure pull leaves that undefined (who reads, and when?).</li>
<li class=""><strong>Batched, level-ordered recompute</strong> — mark dirty on write, then at a flush boundary recompute the dirty nodes once, parents before children. No glitch (a child never runs on a stale parent), no redundant recompute (each node once), and it still <em>pushes</em>, so effects keep a defined firing point. Costs a flush boundary and a topological scheduler.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="what-oimdb-does">What OIMDB does<a href="https://oimdb.org/blog/glitch-free-batched-compute#what-oimdb-does" class="hash-link" aria-label="Direct link to What OIMDB does" title="Direct link to What OIMDB does" translate="no">​</a></h2>
<p>The third. Writes mark dependents dirty and schedule them on <code>OIMComputeRuntime</code>, bucketed by <code>level</code> (graph depth). On the queue's <code>AFTER_FLUSH</code> boundary the runtime drains levels in ascending order — every parent settles before any child runs — and each node recomputes once per flush. In the diamond, <code>D</code> runs a single time, after both <code>B</code> and <code>C</code> are current.</p>
<p>All three on the same workload. The metric here is <strong>recompute count</strong> — how many times nodes re-run for <strong>one</strong> source update, on stacked diamonds (each layer adds a diamond over the previous tip). Count, not time, because that's what separates the options: it's both the wasted work <em>and</em> the number of glitch windows a subscriber could observe.</p>
<table><thead><tr><th>stacked diamonds</th><th>sync push</th><th>pull-on-read</th><th>batched</th></tr></thead><tbody><tr><td>1</td><td>4</td><td>3</td><td>4</td></tr><tr><td>3</td><td>28</td><td>9</td><td>10</td></tr><tr><td>5</td><td>124</td><td>15</td><td>16</td></tr><tr><td>10</td><td>4,092</td><td>30</td><td>31</td></tr></tbody></table>
<p>Sync push ≈2ᵈᵉᵖᵗʰ — every shared descendant re-fires once per path, and each extra recompute is a glitch. Pull-on-read and batched are both linear (~3 per diamond); they do <em>the same work</em>. So the choice between them is not count — it's delivery: batched pushes to subscribers, giving effects a defined firing point, while pull leaves "when does the effect run" to whoever happens to read.</p>
<p>Separately, the wall cost of a single recompute (the time dimension, not the count) is ~<strong>1.6 µs</strong> — so batched at depth 10, ~31 recomputes, is ≈ 50 µs total against sync push's 4,092 ≈ 6.5 ms.</p>
<p><code>.get()</code> is the escape hatch: a direct read calls <code>recomputeAndEmitIfChanged()</code> on the spot, so an imperative read is always live. Only <em>delivery to subscribers</em> waits for the flush.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="cost">Cost<a href="https://oimdb.org/blog/glitch-free-batched-compute#cost" class="hash-link" aria-label="Direct link to Cost" title="Direct link to Cost" translate="no">​</a></h2>
<p>Subscribers observe values at flush boundaries, not at the instant of the write — a mental model to internalize ("the graph settles on flush"). Code that writes and synchronously expects a subscriber to have fired will be surprised, and imperative <code>.get()</code> (live) and push delivery (deferred) are two timing contracts on the same value.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="where-it-lives">Where it lives<a href="https://oimdb.org/blog/glitch-free-batched-compute#where-it-lives" class="hash-link" aria-label="Direct link to Where it lives" title="Direct link to Where it lives" translate="no">​</a></h2>
<ul>
<li class=""><code>packages/core/src/modules/compute/core/OIMComputeRuntime.ts</code> — level-ordered scheduler, <code>AFTER_FLUSH</code> drain.</li>
<li class=""><code>packages/core/src/modules/computed/core/OIMComputed.ts</code> — dirty-tracking, compare-gated emit, live <code>.get()</code>.</li>
<li class=""><code>packages/core/src/modules/effect/core/OIMEffect.ts</code> — schedule-once-per-invalidation.</li>
</ul>]]></content:encoded>
            <category>reactivity</category>
            <category>react</category>
        </item>
        <item>
            <title><![CDATA[Handlers on the carrier, not in a map]]></title>
            <link>https://oimdb.org/blog/handlers-on-carrier</link>
            <guid>https://oimdb.org/blog/handlers-on-carrier</guid>
            <pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[A keyed pub/sub normally keeps a Map. But the code that triggers a notification already holds the object that changed — so hashing the key again to find its subscribers is wasted work.]]></description>
            <content:encoded><![CDATA[<p>A keyed pub/sub normally keeps a <code>Map&lt;key, handlers&gt;</code>. But the code that triggers a notification already holds the object that changed — so hashing the key again to find its subscribers is wasted work.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="context">Context<a href="https://oimdb.org/blog/handlers-on-carrier#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p><code>OIMCarrierKeyedEmitter</code> delivers per key: subscribe to pk <code>42</code>, get notified when <code>42</code> changes. It backs every reactive collection and index. The textbook layout is <code>Map&lt;key, Set&lt;handler&gt;&gt;</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="problem">Problem<a href="https://oimdb.org/blog/handlers-on-carrier#problem" class="hash-link" aria-label="Direct link to Problem" title="Direct link to Problem" translate="no">​</a></h2>
<p>The hot path is <em>marking</em> a changed key dirty — it runs on every mutation. With a map that means hash the key, look up the bucket, check it exists. But the writer that mutated the entity already holds the entity object: it just paid to find the entity, and now pays again to find that entity's subscribers by key.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="options">Options<a href="https://oimdb.org/blog/handlers-on-carrier#options" class="hash-link" aria-label="Direct link to Options" title="Direct link to Options" translate="no">​</a></h2>
<ul>
<li class=""><strong><code>Map&lt;key, Set&lt;handler&gt;&gt;</code> in the emitter</strong> — self-contained; carriers know nothing about subscriptions. Every mark and every delivery is a key hash + map lookup, even though the caller already holds the changed object.</li>
<li class=""><strong>Subscribers on the carrier</strong> — the entity slot (collection) or bucket (index) carries its own <code>subscribers</code> set. The writer holds the carrier, so <code>markUpdatedCarrier(carrier)</code> reads <code>carrier.subscribers</code> directly — no hash, no lookup.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="what-oimdb-does">What OIMDB does<a href="https://oimdb.org/blog/handlers-on-carrier#what-oimdb-does" class="hash-link" aria-label="Direct link to What OIMDB does" title="Direct link to What OIMDB does" translate="no">​</a></h2>
<p>Subscribers live on the carrier (<code>IOIMSubscribable</code>), and the fast mark path never touches a map:</p>
<div class="language-ts codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_t_Hd"><pre tabindex="0" class="prism-code language-ts codeBlock_RMoD thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">public</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">markUpdatedCarrier</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">carrier</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> TCarrier</span><span class="token punctuation" style="color:#393A34">)</span><span class="token operator" style="color:#393A34">:</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">void</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">this</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">assertNotInFlush</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> subscribers </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> carrier</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">subscribers</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token operator" style="color:#393A34">!</span><span class="token plain">subscribers </span><span class="token operator" style="color:#393A34">||</span><span class="token plain"> subscribers</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">size </span><span class="token operator" style="color:#393A34">===</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">return</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">carrier</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">dirty</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">return</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    carrier</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">dirty </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token boolean" style="color:#36acaa">true</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">this</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">dirtyCarriers</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">push</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">carrier</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">this</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">scheduleFlush</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<p>When only a key is available, <code>markUpdatedKey</code> resolves the carrier through an <code>IOIMCarrierResolver</code>.</p>
<p>Two numbers, because the micro-win and the end-to-end win are different sizes. In isolation — just the dispatch, nothing around it — the carrier emitter is <strong>55–68% faster</strong> per key than the <code>Map&lt;key, handlers&gt;</code> emitter (option A); the range is across key-set sizes (the bigger the map, the more the saved hash+lookup is worth). But dispatch is a small slice of a real operation, so the number that matters end-to-end is the second: inside a full index write, delivery is <strong>~14%</strong> of total time with the map emitter and <strong>~0–3%</strong> with the carrier one. Membership bookkeeping, not delivery, dominates the rest — so once delivery is near-free, little is left to shave, and the 55–68% does not translate into a 55–68% faster operation.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="cost">Cost<a href="https://oimdb.org/blog/handlers-on-carrier#cost" class="hash-link" aria-label="Direct link to Cost" title="Direct link to Cost" translate="no">​</a></h2>
<p>The emitter is no longer self-contained: it depends on carrier objects and a resolver. The index case, where the carrier is a standalone per-key object, reintroduces a <code>Map&lt;key, carrier&gt;</code> plus an <code>onCarrierEmptied</code> hook to prune carriers whose last subscriber left — without it a churning key space leaks carriers. A uniform data structure is traded for a faster one whose correctness depends on prune callbacks firing.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="where-it-lives">Where it lives<a href="https://oimdb.org/blog/handlers-on-carrier#where-it-lives" class="hash-link" aria-label="Direct link to Where it lives" title="Direct link to Where it lives" translate="no">​</a></h2>
<p><code>packages/core/src/core/OIMCarrierKeyedEmitter.ts</code>, <code>packages/core/src/types/IOIMSubscribable.ts</code>, <code>packages/core/src/core/OIMKeyedCarrierResolver.ts</code></p>]]></content:encoded>
            <category>internals</category>
            <category>performance</category>
        </item>
        <item>
            <title><![CDATA[getEntitiesByKey returns holes on purpose]]></title>
            <link>https://oimdb.org/blog/holes-contract</link>
            <guid>https://oimdb.org/blog/holes-contract</guid>
            <pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[index.getEntitiesByKey(key) returns (TEntity | undefined)[], aligned 1:1 with getPksByKey(key). The undefineds are the contract, not a bug.]]></description>
            <content:encoded><![CDATA[<p><code>index.getEntitiesByKey(key)</code> returns <code>(TEntity | undefined)[]</code>, aligned 1:1 with <code>getPksByKey(key)</code>. The <code>undefined</code>s are the contract, not a bug.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="context">Context<a href="https://oimdb.org/blog/holes-contract#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>An index maps a key (say <code>teamId</code>) to the entities under it. Each bucket stores <em>slots</em> — <code>{ pk, item }</code> wrappers shared with the collection — so a read returns either the pks (<code>getPksByKey</code>) or the entities (<code>getEntitiesByKey</code>).</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="problem">Problem<a href="https://oimdb.org/blog/holes-contract#problem" class="hash-link" aria-label="Direct link to Problem" title="Direct link to Problem" translate="no">​</a></h2>
<p>An index bucket holds slots <code>{ pk, item }</code>; <code>item</code> is <code>undefined</code> when the pk is reserved but its entity hasn't arrived yet (or was removed) — e.g. message ids indexed before the bodies load.</p>
<p>On read, dropping the empty slots makes the entity array shorter than the pk array, so position <code>N</code> no longer matches. That breaks any index→row mapping (virtual lists).</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="options">Options<a href="https://oimdb.org/blog/holes-contract#options" class="hash-link" aria-label="Direct link to Options" title="Direct link to Options" translate="no">​</a></h2>
<ul>
<li class=""><strong>Compaction</strong> — drop empty slots. Breaks positional alignment, hides "loading".</li>
<li class=""><strong>Throw / filter at the boundary</strong> — pushes an existence check onto every caller, still loses position.</li>
<li class=""><strong>Holes</strong> — one entry per slot, <code>undefined</code> where absent. Stays 1:1 with the pks (LEFT JOIN → NULLs).</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="what-oimdb-does">What OIMDB does<a href="https://oimdb.org/blog/holes-contract#what-oimdb-does" class="hash-link" aria-label="Direct link to What OIMDB does" title="Direct link to What OIMDB does" translate="no">​</a></h2>
<p><code>OIMIndex.slotsToEntities</code> pushes <code>slot.item</code> for every slot, including the <code>undefined</code> ones. So <code>getPksByKey(key)[i]</code> and <code>getEntitiesByKey(key)[i]</code> are the same row: a virtual list renders <code>pks.length</code> rows and shows a skeleton where the entity is <code>undefined</code>.</p>
<p>The non-obvious payoff is that the hole <em>is</em> the loading signal. The instinct — and what a normalizer returning "the entities for this key" usually does — is to surface only the entities that exist. That quietly throws away two things: the row's position, and the fact that the row exists but hasn't loaded yet. With holes the index doubles as a presence map: a known pk with no entity is exactly a hole, so the consumer needs no parallel "which of these are still loading" structure kept in sync with the list. Position carries the identity (<code>pks[i]</code>), the hole carries the state.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="cost">Cost<a href="https://oimdb.org/blog/holes-contract#cost" class="hash-link" aria-label="Direct link to Cost" title="Direct link to Cost" translate="no">​</a></h2>
<p>Callers must handle <code>undefined</code> — TypeScript forces narrowing, mildly annoying when everything is loaded. The alternative, <code>TEntity[]</code> sometimes shorter than its own key list, loses the alignment and gives no signal about which rows are missing — pushing the caller back to a side structure to track partial loads.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="where-it-lives">Where it lives<a href="https://oimdb.org/blog/holes-contract#where-it-lives" class="hash-link" aria-label="Direct link to Where it lives" title="Direct link to Where it lives" translate="no">​</a></h2>
<ul>
<li class=""><code>packages/core/src/abstract/OIMIndex.ts</code> (<code>slotsToEntities</code>, <code>getEntitiesByKey</code>)</li>
<li class=""><code>packages/core/src/types/TOIMEntitySlot.ts</code> (the <code>item: TEntity | undefined</code> slot)</li>
</ul>]]></content:encoded>
            <category>internals</category>
            <category>indexes</category>
        </item>
        <item>
            <title><![CDATA[In-place or immutable? Ship both]]></title>
            <link>https://oimdb.org/blog/in-place-vs-immutable</link>
            <guid>https://oimdb.org/blog/in-place-vs-immutable</guid>
            <pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Updating an entity can either build a fresh merged object or mutate the one already in the store. Each choice serves a different reader, so OIMDB ships both and picks per collection.]]></description>
            <content:encoded><![CDATA[<p>Updating an entity can either build a fresh merged object or mutate the one already in the store. Each choice serves a different reader, so OIMDB ships both and picks per collection.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="context">Context<a href="https://oimdb.org/blog/in-place-vs-immutable#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p>An <em>entity updater</em> decides how a partial patch is applied to the stored entity on <code>upsertOne</code>. <code>OIMCollection</code> calls it on every write. The decision it encodes is whether the stored object keeps its reference or gets a new one.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="problem">Problem<a href="https://oimdb.org/blog/in-place-vs-immutable#problem" class="hash-link" aria-label="Direct link to Problem" title="Direct link to Problem" translate="no">​</a></h2>
<p>That single reference decision splits readers, and one strategy can't serve both:</p>
<ul>
<li class=""><strong>Reference-comparison readers</strong> — React's <code>Object.is</code> / <code>useSyncExternalStore</code>, <code>React.memo</code>, prev/next diffing, time-travel — need a <em>new</em> reference to notice a change.</li>
<li class=""><strong>Subscription readers</strong> — signal hooks that re-read on a keyed notification — want a <em>stable</em> reference and don't compare.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="options">Options<a href="https://oimdb.org/blog/in-place-vs-immutable#options" class="hash-link" aria-label="Direct link to Options" title="Direct link to Options" translate="no">​</a></h2>
<ul>
<li class=""><strong>Merge — <code>{ ...prev, ...draft }</code></strong> — new reference per update, so reference-comparison readers see the change; the previous object is left intact. Costs one allocation + shallow copy per update.</li>
<li class=""><strong>In-place — <code>Object.assign(prev, draft)</code></strong> — zero allocation, stable reference; reference-comparison readers go blind, and a collection read by both a signal hook and a memoized React tree updates one and not the other.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="what-oimdb-does">What OIMDB does<a href="https://oimdb.org/blog/in-place-vs-immutable#what-oimdb-does" class="hash-link" aria-label="Direct link to What OIMDB does" title="Direct link to What OIMDB does" translate="no">​</a></h2>
<p>The default is merge (<code>createMergeEntityUpdater</code>): the common reader is React, where correctness beats a micro-benchmark. In-place is opt-in via <code>createInPlaceEntityUpdater()</code> passed as <code>opts.updateEntity</code>, for collections where <em>every</em> reader is subscription-driven (<code>@oimdb/react</code>'s <code>*Signal</code> hooks).</p>
<p>Data-layer cost per update, no React:</p>
<table><thead><tr><th>updater</th><th>upsert + flush</th></tr></thead><tbody><tr><td>in-place</td><td>0.25 µs</td></tr><tr><td>merge</td><td>0.34 µs</td></tr></tbody></table>
<p>The ~0.09 µs gap is the allocation + shallow copy that merge does and in-place skips. Real on update-heavy data layers; invisible under a React commit (~33 µs).</p>
<p>The mean understates in-place's actual point. Merge's per-update allocation is garbage — under sustained high-frequency writes (a streaming feed, a fast tick loop) that's continuous churn the collector has to reclaim, and the cost surfaces as GC pause tail-latency, not in a 0.09 µs average. In-place allocates nothing, so it produces no such garbage. The flat per-op number is the smaller half of the argument.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="cost">Cost<a href="https://oimdb.org/blog/in-place-vs-immutable#cost" class="hash-link" aria-label="Direct link to Cost" title="Direct link to Cost" translate="no">​</a></h2>
<p>Two updaters with different reference semantics. In-place is faster but fragile — drop it into a tree that relies on <code>Object.is</code> and updates vanish with no error. The footgun is structural: the price of the zero-allocation path.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="where-it-lives">Where it lives<a href="https://oimdb.org/blog/in-place-vs-immutable#where-it-lives" class="hash-link" aria-label="Direct link to Where it lives" title="Direct link to Where it lives" translate="no">​</a></h2>
<ul>
<li class=""><code>packages/core/src/core/createInPlaceEntityUpdater.ts</code></li>
<li class=""><code>packages/core/src/core/createMergeEntityUpdater.ts</code></li>
<li class=""><code>packages/core/src/core/OIMCollection.ts</code></li>
</ul>]]></content:encoded>
            <category>internals</category>
            <category>design</category>
        </item>
        <item>
            <title><![CDATA[Ordered lists as position-addressed commands]]></title>
            <link>https://oimdb.org/blog/position-addressed-commands</link>
            <guid>https://oimdb.org/blog/position-addressed-commands</guid>
            <pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[For an ordered per-key list, OIMDB doesn't hand you a new array to diff. It emits a stream of commands — insert, remove, move, set, reset — each addressed by position, so an imperative renderer can replay them onto a DOM list with no diff.]]></description>
            <content:encoded><![CDATA[<p>For an ordered per-key list, OIMDB doesn't hand you a new array to diff. It emits a stream of commands — insert, remove, move, set, reset — each addressed by position, so an imperative renderer can replay them onto a DOM list with no diff.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="context">Context<a href="https://oimdb.org/blog/position-addressed-commands#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p><code>OIMOrderedListCommandStream</code> wraps an ordered index (a slot list per key) and exposes it as a replayable command stream — for imperative or virtual-list renderers that apply edits straight to the DOM.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="problem">Problem<a href="https://oimdb.org/blog/position-addressed-commands#problem" class="hash-link" aria-label="Direct link to Problem" title="Direct link to Problem" translate="no">​</a></h2>
<p>Ordered lists change incrementally: one row inserted, one moved, a span removed. A consumer that wants minimal DOM mutations needs to know <em>what</em> changed, not just <em>that</em> something did. The default reactive shape — "here's the new list, figure out the delta" — forces every consumer to run a list diff (LCS / keyed reconciliation) on every update, which is exactly the work the producer already did when it mutated the list. If the index knows it inserted one slot at position 4, discarding that and making the consumer rediscover it by diffing is wasted information.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="options">Options<a href="https://oimdb.org/blog/position-addressed-commands#options" class="hash-link" aria-label="Direct link to Options" title="Direct link to Options" translate="no">​</a></h2>
<ul>
<li class=""><strong>Emit the whole new list, diff downstream</strong> — universal, but every consumer pays diff cost on every change and the producer's edit intent is discarded.</li>
<li class=""><strong>Emit pk-level diffs (added/removed sets)</strong> — cheaper than full diffing, but loses position and order: useless for a move, ambiguous about where an insert lands.</li>
<li class=""><strong>Emit position-addressed commands</strong> — the producer states intent directly: "insert at 4", "move 2→5", "remove 3 from 1". No downstream diffing.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="what-oimdb-does">What OIMDB does<a href="https://oimdb.org/blog/position-addressed-commands#what-oimdb-does" class="hash-link" aria-label="Direct link to What OIMDB does" title="Direct link to What OIMDB does" translate="no">​</a></h2>
<p><code>OIMOrderedListCommandStream</code> mutates the underlying ordered index and, per edit, appends the matching <code>TOIMOrderedListCommand</code>, addressed by index. The protocol:</p>
<ul>
<li class=""><code>insert</code> — one element appears at <code>index</code>.</li>
<li class=""><code>remove</code> — <code>count</code> elements (default 1, may be &gt; 1) disappear from <code>index</code>.</li>
<li class=""><code>move</code> — <code>count</code> elements (default 1) move from <code>from</code> to <code>to</code>.</li>
<li class=""><code>set</code> — the element at <code>index</code> is replaced in place (one element, not the list).</li>
<li class=""><code>reset</code> — the whole list for the key is replaced by <code>items</code>.</li>
</ul>
<p>Each writer method (<code>pushSlot</code>, <code>insertSlotAt</code>, <code>removeAt</code>, <code>removeRange</code>, <code>move</code>, <code>moveRange</code>, <code>setSlotAt</code>, <code>setSlots</code>) performs the index mutation and emits the matching command, using the clamped index the mutation actually landed at. Commands buffer per key and deliver once on <code>AFTER_FLUSH</code>.</p>
<p>The axis that decides whether this pays is edit-count vs list-length. Emit-and-diff costs O(list length) per change no matter how small the edit — a reconciler walks the whole list to discover that one row moved. Command replay costs O(edits): one moved row is one <code>move</code>. So the win scales with how big the list is relative to how much of it changes per tick — large list, small incremental edits is where it matters (a 10k-row virtual list nudged by one insert), and it's a wash or worse when most of the list changes at once.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="cost">Cost<a href="https://oimdb.org/blog/position-addressed-commands#cost" class="hash-link" aria-label="Direct link to Cost" title="Direct link to Cost" translate="no">​</a></h2>
<p>Consumers implement a small state machine — replay the protocol against their own list — instead of assigning a new array. That's strictly more consumer code than <code>list = newList</code>, and only some renderers can use it: an imperative/DOM/canvas target that applies edits in place benefits, a React tree that re-renders from a new array gains nothing and still pays the state machine. Whole-list edits also coalesce: a <code>reset</code> supersedes everything buffered before it, and once a batch starts with a reset, later structural edits fold into a fresh reset. So an update that touches the whole list yields a <code>reset</code>, not fine-grained commands — the incremental path isn't automatically cheaper than replacing the array.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="where-it-lives">Where it lives<a href="https://oimdb.org/blog/position-addressed-commands#where-it-lives" class="hash-link" aria-label="Direct link to Where it lives" title="Direct link to Where it lives" translate="no">​</a></h2>
<ul>
<li class=""><code>packages/core/src/modules/wrapper/index/TOIMOrderedListCommand.ts</code></li>
<li class=""><code>packages/core/src/modules/wrapper/index/OIMOrderedListCommandStream.ts</code></li>
</ul>]]></content:encoded>
            <category>internals</category>
            <category>indexes</category>
        </item>
        <item>
            <title><![CDATA[Draining a queue without copying it]]></title>
            <link>https://oimdb.org/blog/queue-double-buffer</link>
            <guid>https://oimdb.org/blog/queue-double-buffer</guid>
            <pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Flushing a task queue usually means snapshotting it first, so tasks enqueued during the drain don't corrupt the walk. That snapshot is an allocation on the hottest path. A double buffer avoids it.]]></description>
            <content:encoded><![CDATA[<p>Flushing a task queue usually means snapshotting it first, so tasks enqueued during the drain don't corrupt the walk. That snapshot is an allocation on the hottest path. A double buffer avoids it.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="context">Context<a href="https://oimdb.org/blog/queue-double-buffer#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p><code>OIMEventQueue.flush()</code> runs every pending task. A task may enqueue more tasks — a handler writes, which marks another carrier dirty, which enqueues its flush. Those new tasks must land in the <em>next</em> flush, not this one, and the current drain must not observe them.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="problem">Problem<a href="https://oimdb.org/blog/queue-double-buffer#problem" class="hash-link" aria-label="Direct link to Problem" title="Direct link to Problem" translate="no">​</a></h2>
<p>Iterating <code>this.tasks</code> directly is unsafe here: as above, a task can enqueue more tasks mid-flush, and a <code>Set</code> mutated while it is being iterated would run that next-flush work now. The usual fix is to snapshot — copy <code>this.tasks</code> into an array, clear the set, then walk the array; new enqueues land in the empty set and the walk is over a frozen list. It works, but the snapshot is a fresh array sized to the queue, allocated on every flush — and flush runs once per microtask under load.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="options">Options<a href="https://oimdb.org/blog/queue-double-buffer#options" class="hash-link" aria-label="Direct link to Options" title="Direct link to Options" translate="no">​</a></h2>
<ul>
<li class=""><strong><code>Array.from(this.tasks)</code> snapshot per flush</strong> — simple and correct; one array allocation proportional to queue size, every flush.</li>
<li class=""><strong>Iterate the live <code>Set</code>, clear at the end</strong> — no snapshot, but new enqueues during the drain land in the same set being iterated; a <code>Set</code>'s <code>for..of</code> visits entries added mid-iteration, so next-flush work runs this flush.</li>
<li class=""><strong>Two sets, swapped (double buffer)</strong> — keep a spare empty <code>Set</code>; swap it into <code>this.tasks</code> and drain the old one; clear the drained set and keep it as the next spare. No per-flush allocation.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="what-oimdb-does">What OIMDB does<a href="https://oimdb.org/blog/queue-double-buffer#what-oimdb-does" class="hash-link" aria-label="Direct link to What OIMDB does" title="Direct link to What OIMDB does" translate="no">​</a></h2>
<p>Double buffer — two <code>Set</code>s allocated once for the queue's lifetime, swapped each flush:</p>
<div class="language-ts codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_t_Hd"><pre tabindex="0" class="prism-code language-ts codeBlock_RMoD thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> flushing </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">this</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">tasks</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">this</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">tasks </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">this</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">tasksSpare</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">this</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">flushing </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> flushing</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">for</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> task </span><span class="token keyword" style="color:#00009f">of</span><span class="token plain"> flushing</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token function" style="color:#d73a49">task</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">flushing</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">clear</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">this</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">tasksSpare </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> flushing</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token keyword" style="color:#00009f">this</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">flushing </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">undefined</span><span class="token punctuation" style="color:#393A34">;</span><br></div></code></pre></div></div>
<p>Enqueues during the drain hit the fresh <code>this.tasks</code> and run next flush — the snapshot's semantics, with no per-flush allocation. The <code>flushing</code> reference is exposed so <code>cancel(fn)</code> can delete a task mid-drain; a <code>Set</code>'s <code>for..of</code> skips entries removed before they're reached, so cancelling a not-yet-run task stops it.</p>
<p>Measured on an <strong>empty flush</strong>, where the array allocation is the <em>entire</em> cost: <strong>0.057 µs</strong> with <code>Array.from</code>, <strong>0.037 µs</strong> with the double buffer (−35%). That −35% is the headline only because nothing else runs — under real load the tasks themselves dominate flush time, so the snapshot's share (and the relative win) shrinks. What the double buffer removes is a fixed per-flush overhead: <code>Array.from</code> allocates proportional to the queue, the swap is O(1), so the <em>absolute</em> saving grows with queue size while the <em>relative</em> saving is largest exactly when flushes are cheap and frequent — which, under microtask batching, they are.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="cost">Cost<a href="https://oimdb.org/blog/queue-double-buffer#cost" class="hash-link" aria-label="Direct link to Cost" title="Direct link to Cost" translate="no">​</a></h2>
<p>The queue permanently holds two <code>Set</code>s instead of one — a fixed idle-memory cost traded for zero churn. And <code>this.flushing</code> must be set during the drain and cleared after, or <code>cancel()</code> would dereference a stale set: bookkeeping a plain snapshot wouldn't need.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="where-it-lives">Where it lives<a href="https://oimdb.org/blog/queue-double-buffer#where-it-lives" class="hash-link" aria-label="Direct link to Where it lives" title="Direct link to Where it lives" translate="no">​</a></h2>
<p><code>packages/core/src/core/OIMEventQueue.ts</code></p>]]></content:encoded>
            <category>internals</category>
            <category>performance</category>
        </item>
        <item>
            <title><![CDATA[Iterate the dirty keys, or the whole map?]]></title>
            <link>https://oimdb.org/blog/sparse-vs-dense-dispatch</link>
            <guid>https://oimdb.org/blog/sparse-vs-dense-dispatch</guid>
            <pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[When a handful of keys changed out of thousands subscribed, iterate the changed keys. Only when every subscribed key changed does iterating the whole map win. The emitter picks at flush time.]]></description>
            <content:encoded><![CDATA[<p>When a handful of keys changed out of thousands subscribed, iterate the changed keys. Only when <em>every</em> subscribed key changed does iterating the whole map win. The emitter picks at flush time.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="context">Context<a href="https://oimdb.org/blog/sparse-vs-dense-dispatch#context" class="hash-link" aria-label="Direct link to Context" title="Direct link to Context" translate="no">​</a></h2>
<p><code>OIMUpdateEventEmitter</code> is the <code>Map</code>-based keyed emitter used where the carrier isn't an object the writer already holds — reactive objects, computed nodes, the ordered-list command stream. At flush it delivers to every changed key that has subscribers.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="problem">Problem<a href="https://oimdb.org/blog/sparse-vs-dense-dispatch#problem" class="hash-link" aria-label="Direct link to Problem" title="Direct link to Problem" translate="no">​</a></h2>
<p>There are two collections in play: <code>updatedKeys</code> (what changed this
tick) and <code>keyHandlers</code> (everything subscribed). To deliver, walk one and
probe the other. Which one gets walked matters: walking the smaller and probing the
larger is fewer operations than the reverse.</p>
<p>One detail constrains the whole problem: <code>markUpdatedKeys</code> only records a key
that <em>already has handlers</em> (it skips keys with no subscribers). So <code>updatedKeys</code>
is always a <strong>subset</strong> of <code>keyHandlers</code> — <code>M ≤ K</code>, where <code>M</code> = changed keys and
<code>K</code> = subscribed keys. The two sets can be equal (via <code>markAllUpdated</code>), never
inverted.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="options">Options<a href="https://oimdb.org/blog/sparse-vs-dense-dispatch#options" class="hash-link" aria-label="Direct link to Options" title="Direct link to Options" translate="no">​</a></h2>
<ul>
<li class=""><strong>Sparse — iterate <code>updatedKeys</code>, probe <code>keyHandlers</code>.</strong> Few keys changed → <code>updatedKeys.size</code> map lookups. When nearly everything changed it still pays a map <code>get</code> per key, pricier than the map's own in-place iteration.</li>
<li class=""><strong>Dense — iterate <code>keyHandlers</code>, probe <code>updatedKeys</code>.</strong> Most keys changed → one linear map walk, cheap <code>Set.has</code> per entry. When two of ten thousand changed, all ten thousand get walked to find two.</li>
<li class=""><strong>Heuristic — pick sparse or dense per flush by size</strong> (e.g. <code>flushingKeys.size * 2 &lt; keyHandlers.size</code>). Cheap to compute, but the crossover constant is a guess until measured, and it means carrying both walks plus a branch.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="what-oimdb-does">What OIMDB does<a href="https://oimdb.org/blog/sparse-vs-dense-dispatch#what-oimdb-does" class="hash-link" aria-label="Direct link to What OIMDB does" title="Direct link to What OIMDB does" translate="no">​</a></h2>
<p>Count the lookups. Sparse does <code>M</code> map-<code>get</code>s; dense does <code>K</code> iterations + <code>K</code>
<code>Set.has</code>es. Since <code>M ≤ K</code>, sparse does fewer for any <code>M &lt; K</code>. The exception is
<code>M = K</code> — every subscribed key dirty — where iterating the map in place avoids
<code>K</code> repeated <code>get()</code> re-hashes. Both walks, benchmarked across <code>M/K</code>
(min-of-7, <code>K</code> = 10 000, ms total — lower is better):</p>
<div class="language-text codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_t_Hd"><pre tabindex="0" class="prism-code language-text codeBlock_RMoD thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#393A34"><span class="token plain">M/K    sparse   dense    winner</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">  1%        6      534    sparse</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"> 10%       78      631    sparse</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"> 25%      176      570    sparse</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"> 50%      330      623    sparse</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"> 75%      597      779    sparse</span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">100%      672      606    dense (~10%)</span><br></div></code></pre></div></div>
<p>Dense wins exactly one ratio: <code>M = K</code>, by ~7–10% (holds at <code>K</code> = 1k and 50k too).
Below that sparse wins — by orders of magnitude when few keys changed. So a size
heuristic is doubly wrong with the wrong constant: <code>* 2</code> flips to dense at
<code>M ≥ K/2</code>, across a 50–99% band where sparse still wins, and <code>K/2</code> has nothing to
do with the one ratio (<code>M = K</code>) where dense actually pays.</p>
<p>Be clear about the stake: <code>M = K</code> only happens via <code>markAllUpdated()</code> / <code>clear()</code>,
not on ordinary partial writes, and the win there is ~10% of an already-cheap
dispatch. This is a small optimization on an infrequent path. The transferable
part isn't the speedup — it's the method: the first cut used a <code>* 2</code> size
heuristic on a guessed crossover; a clean min-of-7 sweep showed there is no
crossover region at all, just a single exact ratio, so the right code is an
equality check, not a tuned threshold.</p>
<p><code>runSinglePass</code> switches on that exact condition — <code>M = K</code>, which is what
<code>markAllUpdated()</code> / <code>clear()</code> produce — and uses sparse otherwise:</p>
<div class="language-typescript codeBlockContainer_mQmQ theme-code-block" style="--prism-color:#393A34;--prism-background-color:#f6f8fa"><div class="codeBlockContent_t_Hd"><pre tabindex="0" class="prism-code language-typescript codeBlock_RMoD thin-scrollbar" style="color:#393A34;background-color:#f6f8fa"><code class="codeBlockLines_AclH"><div class="token-line" style="color:#393A34"><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">flushingKeys</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">size </span><span class="token operator" style="color:#393A34">===</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">this</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">keyHandlers</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">size</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token comment" style="color:#999988;font-style:italic">// every subscribed key dirty: iterate the map, skip K get()s</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token keyword" style="color:#00009f">this</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">keyHandlers</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">forEach</span><span class="token punctuation" style="color:#393A34">(</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">handlers</span><span class="token punctuation" style="color:#393A34">,</span><span class="token plain"> key</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token operator" style="color:#393A34">=&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token operator" style="color:#393A34">!</span><span class="token plain">flushingKeys</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">has</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">key</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">return</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token operator" style="color:#393A34">!</span><span class="token plain">handlers </span><span class="token operator" style="color:#393A34">||</span><span class="token plain"> handlers</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">size </span><span class="token operator" style="color:#393A34">===</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">return</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">this</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">notifyHandlers</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">handlers</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">else</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    flushingKeys</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">forEach</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">key </span><span class="token operator" style="color:#393A34">=&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">const</span><span class="token plain"> handlers </span><span class="token operator" style="color:#393A34">=</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">this</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">keyHandlers</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">get</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">key</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">if</span><span class="token plain"> </span><span class="token punctuation" style="color:#393A34">(</span><span class="token operator" style="color:#393A34">!</span><span class="token plain">handlers </span><span class="token operator" style="color:#393A34">||</span><span class="token plain"> handlers</span><span class="token punctuation" style="color:#393A34">.</span><span class="token plain">size </span><span class="token operator" style="color:#393A34">===</span><span class="token plain"> </span><span class="token number" style="color:#36acaa">0</span><span class="token punctuation" style="color:#393A34">)</span><span class="token plain"> </span><span class="token keyword" style="color:#00009f">return</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">        </span><span class="token keyword" style="color:#00009f">this</span><span class="token punctuation" style="color:#393A34">.</span><span class="token function" style="color:#d73a49">notifyHandlers</span><span class="token punctuation" style="color:#393A34">(</span><span class="token plain">handlers</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain">    </span><span class="token punctuation" style="color:#393A34">}</span><span class="token punctuation" style="color:#393A34">)</span><span class="token punctuation" style="color:#393A34">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#393A34"><span class="token plain"></span><span class="token punctuation" style="color:#393A34">}</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="cost">Cost<a href="https://oimdb.org/blog/sparse-vs-dense-dispatch#cost" class="hash-link" aria-label="Direct link to Cost" title="Direct link to Cost" translate="no">​</a></h2>
<p>Two walks and a branch, versus one. But the branch is an exact equality, not a
tuned threshold, so there is no crossover region to mis-place — it triggers only
on all-keys-dirty. Both walks probe membership, so either stays correct if a
subscription changed between mark and flush.</p>
<h2 class="anchor anchorTargetStickyNavbar_tleR" id="where-it-lives">Where it lives<a href="https://oimdb.org/blog/sparse-vs-dense-dispatch#where-it-lives" class="hash-link" aria-label="Direct link to Where it lives" title="Direct link to Where it lives" translate="no">​</a></h2>
<p><code>packages/core/src/core/OIMUpdateEventEmitter.ts</code> (<code>runSinglePass</code>)</p>]]></content:encoded>
            <category>internals</category>
            <category>performance</category>
        </item>
    </channel>
</rss>