What does a DOM batching library such as FastDOM (`fastdom.measure()` / `fastdom.mutate()`) actually do to your DOM code, and why can adding it to one component still leave the page janky?
answer
- two queues, one animation frame
- reads drain before writes
- the guarantee is frame-wide
- reads become asynchronous
- fewer layouts, not cheaper ones
basics
~20 sFastDOM keeps two queues — reads and writes — and flushes them in a single animation frame with every read before every write, so a frame contains one layout instead of many. It only works if all DOM access in that frame goes through it; one unbatched reader elsewhere re-splits the frame.
solid answer
~50 sYou hand it callbacks instead of touching the DOM inline: `fastdom.measure(fn)` queues a read, `fastdom.mutate(fn)` queues a write, and it flushes both queues in one animation frame, reads first. The frame then contains one layout rather than one per interleaving. The price is that your reads become asynchronous — a helper that used to return a width now takes a callback, so call sites have to be restructured, and long-lived components must hold the returned task and `fastdom.clear()` it on teardown or a callback fires against a detached node. The reason a single-component adoption disappoints is that the guarantee is frame-wide, not module-wide: a third-party widget, a UI library reading `scrollTop`, or one unmigrated handler running in the same frame puts a read back after your writes and the sawtooth returns. It also reduces the *number* of layouts, never the cost of one — a genuinely expensive single layout is untouched.
code
javascript · 14 linesimport fastdom from 'fastdom';
// Queue a read, then a write; both flush in one animation frame.
const task = fastdom.measure(() => {
const width = document.body.clientWidth;
fastdom.mutate(() => {
document.querySelector('.banner').style.width = `${width / 2}px`;
});
});
// On teardown, cancel anything that has not flushed yet.
function destroy() {
fastdom.clear(task);
}go deeper
Know that such a library queues reads and writes separately and runs all reads before all writes in one animation frame, so the browser lays out once instead of many times.
Explain the flush model and its ergonomic cost: reads become asynchronous, captured values can go stale, and queued callbacks need cancelling when a component is torn down.
Argue the adoption boundary — the guarantee is frame-wide, so partial migration leaves the sawtooth — and be clear that it reduces the count of layouts, not the cost of an expensive one.
Own the build-versus-adopt and rollout decision: whether frame-wide coordination is a real need across many independent owners, what near-total adoption would cost, and why deleting measurement usually outranks scheduling it.
## What the library is doing A batching library is a scheduler with two queues. `fastdom.measure(fn)` puts `fn` on the read queue, `fastdom.mutate(fn)` puts it on the write queue, and the first call in a frame schedules a flush via `requestAnimationFrame`. At flush time the read queue drains completely, then the write queue drains. Both calls return a task handle you can pass to `fastdom.clear(task)` to cancel a callback that has not yet run. The effect is not magic and it is worth stating plainly: the library does not make layout faster and does not move anything off the main thread. It changes the *order* in which your DOM operations occur, so that the browser is asked for geometry once, before anything has invalidated it, rather than repeatedly after each mutation. A frame that previously did forty layouts does one. ## What it costs you The API has a real ergonomic price. Reads are now deferred, so any function that measured and returned a value has to become callback- or promise-based, and everything upstream of it changes with it: ```js import fastdom from 'fastdom'; fastdom.measure(() => { const width = container.clientWidth; fastdom.mutate(() => { banner.style.width = `${width}px`; }); }); ``` That nesting is idiomatic, and it is also where the two common bugs live. First, values captured in a read can be stale by the time the write runs — the state the write depends on may have changed within the same frame. Second, a queued callback still runs after its component is gone unless you keep the task handle and clear it during teardown; the symptom is a callback writing to a detached element, or a crash on a null reference. ## Why one component is not enough The guarantee a batching library provides is **frame-wide**, not module-wide. Your component may perform all its reads before all its writes, but the frame belongs to the whole page. If any of these happen in the same frame, the interleaving returns: - a third-party widget measuring its own container; - a UI or animation library reading `scrollTop` or `offsetHeight` directly; - an unmigrated handler elsewhere in your own code, especially one attached to the same event; - code that reads geometry inside the write phase of your own batch, which is the easiest self-inflicted version. This is what makes partial adoption disappointing. You have restructured code, absorbed the asynchrony, and the trace still shows a sawtooth — because someone else in the frame put a read after a write. ## What it cannot fix Batching reduces the *number* of layouts, never the cost of one. If a single layout takes 40ms because the document is enormous or deeply interdependent, doing it once instead of forty times still leaves a 40ms frame, and the answer is structural. It also does nothing about script cost, and nothing about work that should not be happening at all — a handler measuring forty elements on every scroll event is still doing forty measurements, just in a tidier order. ## Deciding whether to adopt it The honest ordering of remedies is: delete the measurement first, restructure the loop second, and reach for a scheduler third. Most cases of thrashing are one function, and a local read-then-write refactor fixes them with no dependency and no asynchrony. A shared scheduler earns its place when many independent widgets touch the DOM in the same frame and no single owner can sequence them — a design-system layer, a dashboard of third-party tiles, a legacy codebase where the offending reads are spread across dozens of files. At that point the value is precisely the frame-wide coordination, which is also why the adoption must be near-total to pay off; a rule that half the code follows delivers close to none of the benefit. Many teams write their own twenty-line version rather than take a dependency, because the concept — two arrays, one animation frame, reads then writes — is small. That is a reasonable outcome, and it does not change any of the reasoning above about adoption or limits.
- If the concept is two arrays and one animation frame, why not just write it yourself?Many teams do, and for a single application that is often the right call — the core is small and the dependency is avoidable. What a mature library adds is the edge handling: cancellation, error isolation so one throwing callback does not abandon the rest of the flush, and re-entrancy when a write queues another read. Write your own with your eyes open about those cases, not because the happy path looked short.
- Your team adopts batching and the trace still shows repeated layouts. How do you find who broke the frame?Attribute rather than audit. Take a trace of the interaction, select the layout entries that appear outside the flush, and follow the forced-layout attribution back to the script responsible — it usually names a library or a handler you did not migrate. That turns "someone in this frame reads geometry" into a specific file, which is the only way this scales past a handful of modules.
- Would you introduce a batching library to fix a single slow loop?No. One loop is a local problem with a local fix: hoist the measurement out, or split it into a read pass and a write pass. Adding a scheduler there buys frame-wide coordination you do not need, in exchange for asynchronous reads, teardown obligations, and a dependency. Reach for the scheduler when many independent owners share a frame and nobody can sequence them.
saying these in an interview costs you the question
- Thinks batching makes layout itself faster
- Believes it moves DOM work off the main thread
- Assumes adopting it in one module fixes the frame
- Forgets queued callbacks outlive a destroyed component
- Adds the library instead of removing the measurement