Skip to content

lazy-define scan() schedules per-element requestAnimationFrame, delaying paint on bulk DOM mutations #343

Closed

Description

@alexus37

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.

Activity

  1. added a commit that references this issue on Apr 16, 2026
    c95922a
  2. self-assigned this
    on Apr 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions