git clone https://github.com/Intense-Visions/harness-engineering
T=$(mktemp -d) && git clone --depth=1 https://github.com/Intense-Visions/harness-engineering "$T" && mkdir -p ~/.claude/skills && cp -r "$T/agents/skills/claude-code/perf-event-loop" ~/.claude/skills/intense-visions-harness-engineering-perf-event-loop && rm -rf "$T"
agents/skills/claude-code/perf-event-loop/SKILL.mdEvent Loop
Understand the browser and Node.js event loop processing model — task queues, microtask queue, rendering steps, and task prioritization — to write code that cooperates with the rendering pipeline instead of blocking it.
When to Use
- You need to understand the execution order of
,setTimeout
,Promise.then
, andqueueMicrotaskrequestAnimationFrame - A recursive microtask chain freezes the page because microtasks drain completely before rendering
does not fire immediately and you need to understand why (4ms clamp)setTimeout(fn, 0)- You are choosing between
,setTimeout
,requestAnimationFrame
, orrequestIdleCallback
for scheduling workscheduler.postTask - Animations stutter because non-visual work competes with
callbacksrequestAnimationFrame - You need to understand why a
callback runs before the browser paintsPromise.resolve().then() - Node.js code behaves differently from browser code regarding microtask and I/O ordering
callbacks fire at unexpected times relative to renderingMutationObserver- You are implementing cooperative yielding and need to choose the right scheduling primitive
drift causes visual inconsistencies in animationssetInterval
Instructions
-
Understand the event loop processing model. Each iteration of the event loop follows this sequence:
- Pick one task from the task queue (oldest task from the highest-priority queue)
- Execute the task to completion
- Drain the microtask queue — execute all microtasks, including microtasks queued by microtasks
- If it is time to render (typically every ~16.67ms at 60Hz):
a. Run
callbacks b. Recalculate styles c. Layout d. PaintrequestAnimationFrame - If idle, run
callbacksrequestIdleCallback
-
Know what creates tasks vs microtasks:
Tasks (macrotasks):
/setTimeoutsetInterval- DOM event handlers (click, input, load)
MessageChannel.port.postMessage()
completion callbacks (not the Promise, but the network callback)fetch- I/O callbacks (Node.js)
Microtasks:
/Promise.then
/catchfinallyqueueMicrotask(fn)
callbacksMutationObserver
/async
continuationsawait
-
Use the right scheduling primitive for each job:
// Visual update — runs before next paint requestAnimationFrame(() => { element.style.transform = `translateX(${x}px)`; }); // Non-urgent work — runs when browser is idle requestIdleCallback((deadline) => { while (deadline.timeRemaining() > 5 && tasks.length > 0) { processTask(tasks.shift()); } }); // Yield to browser for input processing — high-priority reschedule await scheduler.yield(); // Background priority work — low priority scheduler.postTask(() => analytics.flush(), { priority: 'background' }); // Immediate microtask — runs before any rendering queueMicrotask(() => cleanupState()); -
Never create infinite microtask loops. Microtasks drain completely before the browser can render or process input. A recursive microtask chain blocks rendering indefinitely:
// CATASTROPHIC — freezes the browser, no rendering ever occurs function processNextItem() { if (items.length > 0) { processItem(items.shift()); queueMicrotask(processNextItem); // queues another microtask before render } } // FIXED — yields to the event loop between items function processNextItem() { if (items.length > 0) { processItem(items.shift()); setTimeout(processNextItem, 0); // schedules a task, allowing render between items } } -
Understand
clamping. Browsers clampsetTimeout(fn, 0)
to a minimum of 4ms after 5 nested calls. This meanssetTimeout
is not truly zero-delay:setTimeout(fn, 0)// First 4 calls: ~0ms delay // After 5th nesting: minimum 4ms delay // For yielding: use scheduler.yield() or MessageChannel instead const channel = new MessageChannel(); channel.port1.onmessage = () => resumeWork(); channel.port2.postMessage(null); // fires before setTimeout, no 4ms clamp
Details
The Rendering Pipeline in the Event Loop
The browser does not render after every task. It renders at the display's refresh rate (typically 60Hz = every 16.67ms). Between renders, multiple tasks and microtask drains can occur. The rendering steps are:
- Run
callbacks (in order of registration)requestAnimationFrame - Recalculate styles (run style invalidation)
- Layout
- Paint (create paint records)
- Composite (send layers to GPU)
If all rAF callbacks and rendering complete in under 16.67ms, the frame is on time. If they exceed 16.67ms, the frame is late and the user sees jank.
Worked Example: Recursive Microtask Rendering Starvation
A data processing module used
queueMicrotask to process items "asynchronously" without blocking the current task. With 10,000 items, each microtask processed one item and queued the next:
// BROKEN — 10,000 microtasks drain without rendering function processChunk() { if (queue.length > 0) { process(queue.shift()); queueMicrotask(processChunk); // never yields to render } }
The browser froze for 2 seconds (10,000 items * 0.2ms each). No frames were painted because microtasks drain completely before rendering. Fix: replace
queueMicrotask with setTimeout(fn, 0) or scheduler.yield() to yield to the event loop between chunks.
Worked Example: React useEffect Microtask Timing
A React component's
useEffect cleanup ran as a microtask (in React 18's concurrent mode). The effect modified the DOM, and the cleanup restored it. Because the cleanup ran as a microtask before paint, this sequence occurred:
- Effect fires: sets
element.textContent = 'Loading...' - Component re-renders, queuing cleanup as microtask
- Cleanup fires (microtask): sets
element.textContent = 'Done' - Browser paints: user only sees 'Done', never sees 'Loading...'
The developer expected the user to see the loading state. The fix: use
setTimeout in the effect to ensure the DOM update renders before the next operation.
Browser vs Node.js Event Loop
The browser event loop has rendering steps integrated. The Node.js event loop has phases:
- Timers —
,setTimeout
callbackssetInterval - Pending callbacks — deferred I/O callbacks
- Poll — retrieve new I/O events, execute I/O callbacks
- Check —
callbackssetImmediate - Close callbacks —
socket.on('close')
Key difference: Node.js has
process.nextTick() which runs before any other microtask in the microtask queue. In the browser, queueMicrotask and Promise.then are equivalent in priority.
Task Prioritization (Scheduler API)
The Scheduler API provides three priority levels:
— interaction responses, should run within the current frameuser-blocking
— updates the user will notice (default)user-visible
— analytics, prefetch, non-urgent workbackground
await scheduler.postTask(() => updateUI(), { priority: 'user-blocking' }); await scheduler.postTask(() => prefetchData(), { priority: 'background' });
Anti-Patterns
Using
for deferral when you mean Promise.resolve().then()
. Microtasks run before rendering. If you want to defer work until after the browser paints, use setTimeout(fn, 0)
setTimeout or requestAnimationFrame + setTimeout (double-rAF pattern). A Promise-based deferral runs immediately in the microtask checkpoint, before any rendering.
Infinite microtask loops. Any recursive pattern using
Promise.then or queueMicrotask that does not eventually yield creates an infinite microtask loop. The browser cannot render, process input, or run timers until the microtask queue is empty.
Assuming
fires immediately. After 5 nested setTimeout(fn, 0)
setTimeout calls, the minimum delay is clamped to 4ms in browsers. For time-sensitive yielding, use MessageChannel or scheduler.yield() which do not have this clamp.
Using
for animations instead of setInterval
. requestAnimationFrame
setInterval fires at fixed wall-clock intervals regardless of the display refresh rate. It can fire between frames (wasted work) or multiple times in one frame (duplicate work). requestAnimationFrame fires exactly once per frame, synchronized with the display.
Blocking the event loop with synchronous I/O in Node.js.
fs.readFileSync, crypto.pbkdf2Sync, and other sync APIs block the entire event loop. All pending I/O, timers, and HTTP requests are stalled. Use async equivalents.
Source
- HTML Living Standard, Section 8.1.7: Event loop processing model — https://html.spec.whatwg.org/multipage/webappapis.html#event-loops
- Jake Archibald, "In The Loop" (JSConf 2018) — https://www.youtube.com/watch?v=cCOL7MC4Pl0
- Node.js Event Loop documentation — https://nodejs.org/en/guides/event-loop-timers-and-nexttick
- Scheduler API specification — https://wicg.github.io/scheduling-apis/
Process
- Read the instructions and examples in this document.
- Apply the patterns to your implementation, adapting to your specific context.
- Verify your implementation against the details and edge cases listed above.
Harness Integration
- Type: knowledge — this skill is a reference document, not a procedural workflow.
- No tools or state — consumed as context by other skills and agents.
Success Criteria
- The patterns described in this document are applied correctly in the implementation.
- Edge cases and anti-patterns listed in this document are avoided.
- The correct scheduling primitive is chosen for each type of work (visual, idle, yielding, background).
- No microtask-based rendering starvation occurs in the application.