JavaScript in the browser
Short answer: In the browser, JavaScript is a single-threaded, event-driven runtime where the event loop schedules tasks and microtasks, and rendering happens only when the main thread is free.
Core mental model (browser JS)
- JavaScript runs inside a renderer process, typically on a single “main thread”.
- The same main thread is responsible for:
- Running your JS
- Handling user input events (dispatching them to listeners)
- Coordinating style/layout/paint work (and calling into the compositor/GPU)
- Result: long JS blocks everything (input lag + dropped frames + delayed rendering).
The event loop in one picture (conceptual)
- There are multiple queues (not just one):
- Task (a.k.a. macrotask) queue(s): timers, DOM events,
postMessage, network callbacks, etc. - Microtask queue: Promise reactions (
.then/.catch/.finally), queueMicrotask, MutationObserver - Render steps: style/layout/paint/composite (not a “queue”, but a phase the browser attempts between tasks)
- Task (a.k.a. macrotask) queue(s): timers, DOM events,
- The loop is: pick a task → run it → run ALL microtasks → (maybe) render → pick next task.
Tasks vs microtasks (what runs when)
Tasks (macrotasks) — “next turn”
Common sources:
setTimeout/setIntervalcallbacks- UI events (click, input, scroll) dispatch
- message channel /
postMessage - some network callbacks (browser-specific scheduling details exist)
Semantics:
- Only one task runs at a time.
- After the task finishes, the browser drains microtasks.
- Rendering is typically attempted between tasks (after microtasks), if needed.
Microtasks — “end of this turn, before render”
Common sources:
- Promise resolution handlers:
promise.then(...) - async/await continuations (await essentially schedules Promise reactions)
queueMicrotask(...)- MutationObserver callbacks
Semantics:
- Microtasks run after the current JS stack unwinds, before the browser renders.
- Microtasks are drained completely (to exhaustion). If microtasks keep scheduling microtasks, rendering can starve.
Key pitfall:
- “Promise.then is faster than setTimeout” is imprecise; it’s “Promise.then runs before the browser gets a chance to render and before the next task”.
Why rendering is tied to the event loop
- The browser generally does not render “in the middle” of running JS.
- Rendering steps need the main thread to:
- Calculate style
- Run layout
- Produce paint records (or update them)
- If JS keeps the main thread busy, frames drop.
Rule of thumb:
- If you want the UI to update, you must yield back to the event loop and allow a render opportunity.
Frame budget and jank (practical performance model)
- On a 60Hz display, a frame is ~16.67ms.
- You don’t get the full 16ms for JS; style/layout/paint and other browser work also need time.
- If a task runs for 50ms:
- Input events are delayed (feels laggy)
- Rendering cannot happen on time (stutters)
- “Jank” is often “main thread work exceeds frame budget”.
Common scheduling tools and what they mean
setTimeout(fn, 0)
- Schedules fn as a task in a future turn.
- It yields to the browser: microtasks drain, then the browser may render, then your timeout runs later.
- Not deterministic timing; it’s “not now”.
Use cases:
- Break up long work into chunks.
- Allow DOM updates / paint to occur before continuing.
Pitfall:
- Timer clamping (minimum delay), especially in background tabs.
- Not aligned with frames.
Promise.resolve().then(fn) / queueMicrotask(fn)
- Schedules fn as a microtask.
- Runs before the browser renders and before the next task.
- Useful for “after current call stack” logic, but can starve rendering if overused.
Use cases:
- Ensure something happens after current synchronous code.
- Batch state updates before letting the browser render.
Pitfall:
- Infinite microtask chains block rendering:
then(() => Promise.resolve().then(...))in a loop can freeze the UI.
requestAnimationFrame(fn)
- Schedules fn to run before the next repaint (frame).
- Browser calls fn with a high-resolution timestamp.
- Intended for visual updates (animations, measuring layout in sync with rendering).
Use cases:
- Animate based on time deltas.
- Coordinate DOM reads/writes around frames.
Pitfall:
- If your rAF callback is heavy, you still drop frames.
- Not guaranteed in background tabs (throttled/paused).
requestIdleCallback(fn)
- Calls fn when the browser is “idle” (has spare time).
- Not reliable for critical work; can be delayed significantly.
- Great for low-priority tasks.
Use cases:
- Precompute, prefetch, analytics, cache warming.
Pitfall:
- Not supported uniformly in all environments; timing is unpredictable.
A precise example: ordering (task vs microtask vs render)
Consider:
- In a click handler (task):
- Update DOM text
- Schedule a Promise.then (microtask)
- Schedule a setTimeout (task)
- What tends to happen:
- click handler runs (task)
- microtasks run (Promise.then)
- browser may render (show DOM change)
- setTimeout runs later (next task)
Important nuance:
- The DOM mutation in step (1) does not necessarily paint until after microtasks drain.
- If microtasks run long, paint is delayed.
async/await in the browser (how it maps to microtasks)
- async functions return Promises.
- await pauses the function and schedules the continuation as a Promise reaction (microtask) once awaited Promise resolves.
- If you “
await Promise.resolve()” you yield to microtasks, not to rendering necessarily (render happens after microtasks).
Pitfall:
- “await” is not “let the browser render now”.
- If you want to yield to rendering, consider awaiting a task boundary (e.g., setTimeout) or using rAF depending on intent.
DOM events and input responsiveness
- Input events (click, keydown, input) are dispatched as tasks.
- If the main thread is busy, the event waits in the queue.
- If your handler does heavy work:
- You block subsequent input
- You block rendering
- Strategy: do minimal work in handlers, schedule heavy work elsewhere (chunking).
Layout thrashing (JS + rendering interaction trap)
Layout thrashing is alternating DOM writes and reads that force repeated layout.
Mechanism:
- Certain DOM reads require up-to-date layout (e.g., offsetHeight, getBoundingClientRect).
- If you write to DOM (change class/style) then read layout, the browser may have to:
- Flush pending style changes
- Recompute layout immediately
- Repeating this in a loop causes many forced layouts.
Pattern to avoid:
- write → read → write → read in tight loops
Better pattern:
- Batch reads first, then batch writes:
- Read all needed layout data
- Then apply all DOM mutations
- Or do writes in rAF to align with frame boundary.
Microtasks and rendering starvation (real failure mode)
Example failure mode:
- A function schedules a microtask that schedules another microtask, repeatedly.
- Because the browser drains microtasks fully before rendering, rendering never happens.
- Symptoms:
- Tab looks frozen
- CPU spikes
- No repaint even though DOM changes occur
Mitigation:
- Insert task boundaries:
setTimeout(fn, 0)occasionally- or
postMessage/MessageChannelyields
- Keep microtasks bounded and short.
Web Workers (what “multithreading” really means in browser JS)
- Workers run JS off the main thread.
- They cannot access the DOM directly.
- Communication is via message passing (structured clone) and optionally SharedArrayBuffer (advanced).
Use cases:
- CPU-heavy computation (parsing, compression, crypto, analytics)
- Keep main thread responsive
Pitfalls:
- Serialization cost (copying large data)
- Need careful design for data flow
- Not a free win unless work is substantial
Timers, throttling, and background tabs
- Browsers throttle timers in background tabs to save power.
- requestAnimationFrame may be paused or heavily throttled in background.
- setTimeout minimum delay is increased in some conditions.
- Do not rely on precise timer behavior for correctness.
Correctness principle:
- Timers are hints, not guarantees.
- Use server time or monotonic clocks (performance.now) for measuring elapsed time, and design for delays.
Fetch and promises: where network fits
fetch(...)returns a Promise.- “Response arrived” leads to Promise resolution handlers (microtasks) running when the main thread is free.
- Even if network completes quickly, JS can’t observe it until it gets scheduled.
- This is why heavy JS can make “fast API” feel slow.
Practical patterns for backend engineers
Pattern: keep handlers light
- Do minimal parsing/validation in event handlers.
- Defer heavy work via tasks or workers.
Pattern: chunk long work
- For loops over large arrays: process in slices, yielding between slices.
- This preserves responsiveness and allows rendering.
Pattern: separate DOM reads and writes
- Reads (measure) first, writes (mutate) after, ideally once per frame.
Pattern: choose the right scheduler
- Need “after current stack, before render”: microtask (
queueMicrotask/Promise.then) - Need “let render/input happen”: task boundary (
setTimeout/ MessageChannel) - Need “align with repaint”:
requestAnimationFrame - Need “whenever spare time”:
requestIdleCallback
Interview traps (high-signal)
- “JavaScript is single-threaded”: true for main thread execution, but the platform is multithreaded (network, compositor, workers).
- “await yields to event loop”: it yields to microtasks; rendering still waits until microtasks drain.
- “Promise.then is asynchronous”: yes, but it is prioritized before rendering and before next task.
- “DOM updates are immediate”: DOM mutations are immediate in memory, but painting is deferred.
Quick reference table
| Mechanism | Queue/Phase | When it runs | Best for | Common pitfall |
|---|---|---|---|---|
setTimeout(fn, 0) | Task | Future turn | Yielding to render/input, chunking | Timing not guaranteed, throttling |
Promise.then / queueMicrotask | Microtask | End of current turn, before render | Post-stack logic, batching | Starves rendering if chained |
requestAnimationFrame | Render-aligned | Before next repaint | Animations, visual updates | Heavy callback drops frames |
requestIdleCallback | Idle | When browser is idle | Low-priority work | Can be delayed a lot |
| Web Worker | Separate thread | Parallel to main thread | CPU-heavy work | Serialization overhead, no DOM |