skip to content

A runtime that dirty-checks the whole component tree after every event slows as the tree grows - why, and how is the check narrowed?

level: seniorimportance: should knowfreq 44%

answer

  1. cost follows what is checked
  2. not what changed
  3. bindings per pass, times passes
  4. mark subtrees, compare inputs by reference

basics

~20 s

Cost follows bindings compared per pass times the number of passes, not how many values changed. Narrowing means walking only subtrees the runtime was told may have changed, which requires unchanged inputs to be recognisable by reference.

solid answer

~50 s

Dirty checking intercepts no writes. At trigger points it knows about - a handler returning, a timer firing, a request completing - it walks the tree, compares each binding's current value with the value last rendered, and updates the differences. So the bill is bindings checked times passes, and it grows with the tree even on an event that changed nothing. Extra passes come from checks whose own work changes another value, which forces a re-walk until things settle, normally under a hard cap. The narrowing is to stop walking everything: mark only the component whose inputs changed or in which the event fired, plus the path to it, and skip the rest. That is affordable only if a change is cheap to spot at the boundary - typically a changed reference - which pushes real discipline onto how inputs are produced.

go deeper

for a junior

Hold on to the shape: this model compares what is on screen with the values behind it at certain moments, so the work depends on how much is on screen, not on how much changed.

for a middle

Explain the two multipliers - bindings per pass and passes per trigger - and where extra passes come from. Then say why marking subtrees requires comparing inputs by reference.

for a senior

Do the diagnosis by counting: time per pass against rows rendered, passes per trigger, triggers per second. Name the narrowing you would apply and the discipline it imposes on input producers.

for a principal

The multiplier is a platform property of the app, so treat budgets as policy: how large a checked region a screen may present, where high-frequency sources are allowed, and who reviews the input-stability rules the narrowing depends on.

## What dirty checking actually does This is the one model where a write is entirely ordinary. Nothing is intercepted, no handle is involved, no compiler rewrote anything. Instead the runtime, at moments it knows about, asks the whole tree a question: for every binding on screen, is the current value still the value I last rendered? Where the answer is no, it updates. Two things make that workable at all: - **Known trigger points.** The runtime must be told when to look. It learns about user events because it dispatches the handlers, and about asynchronous completions because the asynchronous entry points are wrapped so that finishing one also triggers a check. A write from an entry point the runtime does not know about is simply not noticed until something else triggers a check - which is exactly the classic "the value changed but the screen did not" report. - **Cheap comparisons.** Each binding keeps the value it last rendered, and the comparison is usually a reference or primitive check, so per-binding cost is tiny. The model's whole bet is that tiny times many is still acceptable. ## Why cost grows with the tree The walk is proportional to **bindings visited**, not to bindings that changed. A click that changes one label in a page with eight thousand bindings still costs an eight-thousand-comparison pass, and the numbers that matter in production are: - bindings per pass - grows with what is on screen, which is why large tables and long lists are where this model is felt; - passes per trigger - more than one when a check's own work changes something; - triggers per second - one careless high-frequency source, such as a pointer-move listener or a short interval, multiplies both of the above. What users feel is input latency: the walk happens between their action and the frame that shows its result. ## Why there can be more than one pass If the work done during a check changes another value - a derived field recomputed into a property, a callback that writes state - then the tree that was already walked may no longer be consistent, so the runtime walks again. It repeats until a pass finds no differences, and because a pair of values can chase each other forever, implementations cap the number of passes and report a failure to settle rather than hang. A binding whose expression returns a **freshly built object or a new function on every evaluation** never compares equal to last time, so it is a reliable way to produce that failure: the check finds a difference on every pass, forever. ## Narrowing the check The fix is not a faster comparison; it is fewer comparisons. In order of how much they buy: 1. **Check only marked subtrees.** Instead of walking from the root, mark the component whose inputs changed or in which the event fired, plus the ancestor path needed to reach it, and skip every unmarked subtree. Work now follows the changed region rather than the page. 2. **Make a change detectable at the boundary.** Skipping a subtree is only sound if the runtime can decide cheaply that the subtree's inputs are unchanged - in practice a reference comparison. So inputs must be new references when they change and stable references when they do not, which is real discipline on the producing side. 3. **Keep expensive work out of bindings.** A binding's expression is evaluated on every pass that visits it, so anything that builds, sorts, filters or allocates belongs in a value computed when its inputs change, not in the expression. 4. **Reduce triggers.** Take high-frequency sources out of the checked set, batch them, or let them update a narrow region directly rather than triggering a whole-tree check per event. 5. **Reduce what is on screen.** Windowing a long list cuts bindings per pass more than any of the above, because it attacks the multiplier directly. ## Confirming it rather than guessing The diagnosis is a counting exercise, and it is worth stating as one in an interview: measure **how long a check takes**, **how many passes each trigger causes**, and **how many bindings a pass visits**. If time per pass tracks the number of rows on screen, the walk is the problem. If passes per trigger is above one, something written during a check is changing values - find it, because it doubles everything. If triggers per second is high, the walk may be fine and the trigger source is the bug. Compare a busy screen against a nearly empty one in the same app: cost that scales with what is rendered rather than with what changed is the signature of this model. ## How it differs from the other three | | Dirty checking | The intercepted-write models | |---|---|---| | How a change is found | comparison after the fact | the write itself is the signal | | Cost driver | bindings checked, times passes | re-run subtree, or dependent count | | Needs special value types | no - plain objects are fine | a handle, a setter, or a compiled shape | | Typical failure | a write the runtime was never triggered for | a write that bypassed the tracked path | The honest summary is that dirty checking asks the least of your data and the most of your tree. It is the model that most rewards knowing exactly how large the checked region is, which is why the narrowing mechanisms above are not optimisations bolted on afterwards - they are how the model is expected to be used at scale.

  • Why does a binding whose expression builds a new object on each evaluation break a dirty-checking runtime?
    Because the check compares the current value with the last-rendered one, and a freshly built object is never the same reference. Every pass sees a difference, so the runtime keeps re-walking until it hits its pass cap and reports a failure to settle. The fix is to compute the value when its inputs change and render the stable result.
  • What happens when state is written from an entry point the runtime was never told about?
    Nothing, until something else triggers a check. The value in memory is correct and the screen is stale, which is why such runtimes wrap the asynchronous entry points they know about and expose a way to say "check now" for the ones they do not.
  • Why does narrowing the check push work onto the code that produces a component's inputs?
    Because skipping a subtree requires deciding cheaply that its inputs did not change, which in practice means comparing references. Inputs must therefore be new references exactly when they change and stable when they do not - so a rebuilt object or an inline function defeats the skip and silently restores the wide walk.

A night watchman walking every corridor on a fixed round: the round takes as long as the building is big, whether or not a single door was actually opened.

saying these in an interview costs you the question

  • Thinks the check costs are proportional to the number of values that changed.
  • Says a faster comparison per binding is the fix for a slow check.
  • Believes the check runs continuously rather than at known trigger points.
  • Assumes one trigger always produces exactly one pass over the tree.
  • Treats narrowing as an internal optimisation requiring nothing of the developer.