The Dance Of JavaScript In the Browser
High-level concepts you must understand.
- Useful patterns & examples
- Common pitfalls & gotchas
- Testing & determinism
- Performance & battery considerations
High-level concepts you must understand.
- Event loop phases / task queues
- While the event loop itself isn’t directly DOM-related, many tasks scheduled by the event loop can be. For instance:
- *Microtask(like
.then
of aPromise
) can modify the DOM (e.g., updating elements one aPromise
resolves). - Macrotasks (like
setTimeout
,setInterval
, orrequestAnimationFrame
) can also interact with the DOM by manipulating elements, triggering events, or performing layout/repaint tasks. -
setTimeout
/setInterval
callbacks are scheduled on the macrotask (task) queue (in browsers the “timer” phase). Microtasks (Promises.then
,queueMicrotask
) run before macrotasks that follow the currently executing code. - Consequence: resloving a promise can run before a
setTimeout(..., 0)
handler.
- *Microtask(like
- While the event loop itself isn’t directly DOM-related, many tasks scheduled by the event loop can be. For instance:
- Zero-delay is not immediate
-
setTimeout(fn, 0)
schedulesfn
to run after current code and any queued microtasks - it is yielding control, not instantaneously.
-
- setTimeout / setInterval Callbacks:
- These are typically used for scheduling DOM updates, such as animations or polling for state changes, so they are often DOM-related when used in client-side JavaScript.
- For example, you might use
setTimeout
to delay DOM updates after a certain action, orsetInterval
to repeatedly update an element at a fixed interval.
- Clamping and throttling
- These can impact the performance of DOM updates. If your’re using high-frequency timers to update the DOM, browsers may throttle them when tabs are inactive or when there are too many nested timers. This is particularly important in animations or continous DOM updates.
- Browsers clamp nested timers or timers in background/inactive tabs. Historically nested timeouts < 4ms get clamped to 4ms; background tabs may be throttled to ~100ms or more. Node has different rules.
- Accuracy and Drift
- Timers that drift can cause issues with timing-sensitive DOM updates (like animations or periodic UI updates). For example, if you rely on
setInterval
to update an animation, any drift can result in visible jank or inconsistent behavior in the UI.
- Timers that drift can cause issues with timing-sensitive DOM updates (like animations or periodic UI updates). For example, if you rely on
- Timers and Async Functions
-
If you pass asn async function to a
setInterval
orsetTimeout
, it won’t behave as expected, since the function will run asynchronously. This could lead to race conditions or unexpected behavior when manipulating the DOM.For example, if the async function updates the DOM, it could result in multiple concurrent updates that might not be desirable.
-
- Memory Leaks and Closures
- This is very relevant for DOM manipulation, as closures (functions referencing DOM nodes) can prevent garbage collection if not cleared properly. If you don’t clear your timers (especially if they are tired to DOM elements), you might create memory leaks that accumulate over time as the DOM grows.
- Security / DoS / Throttlingh
- While not directly manipulating the DOM, this is relevant to DOM performance. Excessive timers can throttle DOM updates or cause a page to become unresponsive ( leading to poor user experience ). For instance, long-running intervals or timeouts that update the DOM can overwhelm the browser’s rendering enginer,, causing frames to drop or UI freezes.
Useful patterns & examples
-
Promise sleep /
await
friendlyfunction sleep(ms, {signal} = {}){ return new Promise((resolve, reject) => { if(signal?.aborted) { return reject(new DOMException('Aborted', 'AbortError')); } const id = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms); function onAbort() { clearTimeout(id); reject(new DOMExecption('Aborted', 'AbortError')); } signal?.addEventListenter('abort', onAbort, {once: true}); }); }
This is better than ad-hoc setTimeouts when you use
async/await
and want cancellation support. -
Accurate interval (drift-compensated)
function startAccurateInterval(fn, interval) { let expected = performance.now() + interval; let stopped = false; function tick() { if (stopped) { return; } fn(); expected += interval; const next = Math.max(0, interval - drift); setTimeout(tick, next); } setTimeout(teck, interval); return () => { stopped = true; }; } //usage const stop = startAccurateInterval(() => console.log('tick', performance.now()), 1000); // stop() to cancel
This corrects for drift caused by callback execution time.
-
Avoid overlapping
async
interval runlet running = false; const id = setInterval(async () => { if (running) return; running = true; try { await doSomethingAsync(); } finally { running = false; } }, 1000)
Or use recursive
setTimeout
to enforce sequential runs. -
Exponential backoff + jitter (for retries)
function backoff(attemp, base = 200, cap = 10000){ const exp = Math.min(cp, base * (2 ** attemp)); // add jitter return exp / 2 + Math.random() * (exp / 2) }
Use
setTimeout
with the returned ms. Jittering avoids thundering herd. -
Cancelable timers with AbortController
function setTimeoutWithSignal(cb, ms, signal) { const id = setTimeout(() => { signal?.removeEvenListener('abort', onAbort); cb(); },ms) function onAbort(){ clearTimeout(id); } signal?.addEventListener('abort', onAbort, {once: true}); return id; } const ac = new AbortController() { setTimeoutWithSignal(() => console.log('done'), 5000, ac.signal); ac.abort(); // cancels }
- Use
requestAnimationFrame
for animations.-
requestAnimationFrame
is synchronized with display refresh rate and pauses in background tabs - use it instead ofsetInterval
for visual updates.
-
-
Yiedling to the event loop (cooperative blocking) If you must do heavy sychronous work but want to keep UI responsive, break it into slices:
async function bigWork(items) { for (let i = 0; i<items.length; i++) { process(items[i]); if(i % 100 === 0){ await new Promise(r => setTimeout(r, 0)); // yield } } }
Common pitfalls & gotchas
- Relying on exact timing - browsers and OSes will vary. Never assume millisecond-perfect scheduling for non-real-time tasks.
- setInterval + long task overlap - causes multiple simultaneous executions; use locking or recursive scheduling.
- Timers keep objects alive - forgettiing to clear timers tied to DOM nodes leads to leaks.
- Using
setInterval
for animations - leads to frame-skip/jitter; preferrequestAnimationFrame
. - Nested setTimeout clamping - repeatedly calling ~setTimeout(…, 0) inside handlers can get clamped to a minimum delay.
- In tests - use fake timers(Jest, Sinon) for deterministic behavior. Be aware fake timers change how
Date.now()
andperformance.now()
behave in some libs.
Testing & determinism
- Use Sinon or Jest fake timers to:
- Advance time deterministically.
- Test backoff and retry logic.
- Avoid flakiness in asynchronours tests.
- But beware: some APIs (like
requestAnimationFrame
, or high-resperformace.now()
) need additional shims or cannot be faked the same way.
Performance & battery considerations
- Avoid frequent timers on background tabs; check
document.visibilityState
and pause timers when hidden. - For background processing use Web Workers or Service Workers where appropriate (and the browser allows longer lifecycle).