Summary
When many elements are added to the DOM in a single frame (e.g., a framework rendering a list), the scan() function in lazy-define.ts schedules a separate requestAnimationFrame callback for each element. Since all rAF callbacks run before the browser paints, this delays the first paint proportionally to the number of added elements.
Details
The scan() function uses a WeakMap<ElementLike, number> keyed by each individual element to deduplicate:
const timers = new WeakMap<ElementLike, number>()
function scan(element: ElementLike) {
cancelAnimationFrame(timers.get(element) || 0)
timers.set(
element,
requestAnimationFrame(() => {
for (const tagName of dynamicElements.keys()) {
const child = element instanceof Element && element.matches(tagName)
? element : element.querySelector(tagName)
// ...
}
})
)
}
The cancelAnimationFrame/requestAnimationFrame pairing is intended to coalesce multiple calls — but only works when scan() is called repeatedly with the same element.
The MutationObserver in observe() calls scan() with a different element for each added node:
elementLoader ||= new MutationObserver(mutations => {
if (!dynamicElements.size) return
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node instanceof Element) scan(node) // different element each time
}
}
})
Since each element gets its own WeakMap entry and its own rAF, none of the cancelAnimationFrame calls actually cancel anything.
Impact
When a framework commits a list of 25 items (each with ~5 child elements), this creates ~127 rAF callbacks that all execute before the browser can paint. In profiling, this adds ~17ms of JS execution and, more importantly, delays paint by a full animation frame because the browser cannot paint until all queued rAF callbacks finish.
The browser frame lifecycle is: JS tasks → rAF callbacks → Style/Layout → Paint. Flooding the rAF queue pushes paint later than necessary.
Suggested Fix
Use a single shared rAF timer that coalesces all elements from one mutation batch:
const pendingElements = new Set<ElementLike>()
let scanTimer: number | null = null
function scheduleScan(element: ElementLike) {
pendingElements.add(element)
if (scanTimer == null) {
scanTimer = requestAnimationFrame(() => {
scanTimer = null
if (!dynamicElements.size) {
pendingElements.clear()
return
}
for (const el of pendingElements) {
for (const tagName of dynamicElements.keys()) {
const child = el instanceof Element && el.matches(tagName)
? el : el.querySelector(tagName)
if (customElements.get(tagName) || child) {
const strategyName = child?.getAttribute("data-load-on") || "ready"
const strategy = strategyName in strategies ? strategies[strategyName] : strategies.ready
for (const cb of dynamicElements.get(tagName) || []) strategy(tagName).then(cb)
dynamicElements.delete(tagName)
}
}
}
pendingElements.clear()
})
}
}
This ensures that no matter how many elements are added in a single frame, only one rAF callback runs before paint.
Summary
When many elements are added to the DOM in a single frame (e.g., a framework rendering a list), the
scan()function inlazy-define.tsschedules a separaterequestAnimationFramecallback for each element. Since all rAF callbacks run before the browser paints, this delays the first paint proportionally to the number of added elements.Details
The
scan()function uses aWeakMap<ElementLike, number>keyed by each individual element to deduplicate:The
cancelAnimationFrame/requestAnimationFramepairing is intended to coalesce multiple calls — but only works whenscan()is called repeatedly with the same element.The
MutationObserverinobserve()callsscan()with a different element for each added node:Since each element gets its own
WeakMapentry and its own rAF, none of thecancelAnimationFramecalls actually cancel anything.Impact
When a framework commits a list of 25 items (each with ~5 child elements), this creates ~127 rAF callbacks that all execute before the browser can paint. In profiling, this adds ~17ms of JS execution and, more importantly, delays paint by a full animation frame because the browser cannot paint until all queued rAF callbacks finish.
The browser frame lifecycle is:
JS tasks → rAF callbacks → Style/Layout → Paint. Flooding the rAF queue pushes paint later than necessary.Suggested Fix
Use a single shared rAF timer that coalesces all elements from one mutation batch:
This ensures that no matter how many elements are added in a single frame, only one rAF callback runs before paint.