How React Fiber Eliminates UI Jank: A Step‑by‑Step Deep Dive
This article explains why web pages feel sluggish, how React Fiber’s interruptible asynchronous rendering solves the jank problem, and walks through progressively refined implementations—from a basic React demo to a full Fiber renderer with work‑loop scheduling, diffing, and performance optimizations.
Why UI Jank Happens
Jank is the perception of choppy rendering. Human visual perception is most comfortable at 50‑60 Hz, which corresponds to a 60 fps baseline. When the browser’s render interval exceeds the ~16.6 ms needed for one 60 Hz frame, the animation appears disjointed.
Human perception : the eye detects motion best between 50 Hz and 60 Hz.
Frame rate vs. smoothness : higher fps yields more static images per second, creating smoother motion.
Render interval : intervals > 16.6 ms cause visible jitter.
Browser Rendering Cycle
The browser’s event loop does not render on every tick; rendering occurs only when there is idle time. Key questions include:
Does every event‑loop tick trigger a render?
When does requestAnimationFrame run—before or after painting, and relative to micro‑tasks?
When does requestIdleCallback run, and can it be forced with a timeout?
When are resize and scroll events dispatched?
Rendering is not guaranteed each loop; it occurs when there are pending micro‑tasks. requestAnimationFrame runs before the browser paints, making it ideal for animation work. requestIdleCallback runs after painting and may be delayed unless a timeout is supplied. resize and scroll are throttled and dispatched during the render phase of the event loop.
How Fiber Solves Jank
React Fiber introduces interruptible asynchronous rendering. The synchronous, non‑interruptible update process is split into small work units that can be paused, allowing the browser to handle higher‑priority tasks and then resume rendering.
Native React Example
import React from 'react';
import ReactDOM from 'react-dom';
const container = document.querySelector("#root");
const element = React.createElement('div', {title: 'div', name: 'div'},
'div ',
React.createElement('h1', null, 'h1'),
React.createElement('p', null, 'p'),
React.createElement('h2', null, 'h2')
);
ReactDOM.render(element, container);Implementing createElement and render
createElement.js
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children?.map(child => typeof child === 'object' ? child : createTextElement(child))
}
};
}
function createTextElement(text) {
return { type: 'TEXT_ELEMENT', props: { nodeValue: text, children: [] } };
}
export default { createElement };render.js (first version)
function render(element, container) {
const dom = element.type === 'TEXT_ELEMENT'
? document.createTextNode('')
: document.createElement(element.type);
const isProperty = key => key !== 'children';
Object.keys(element.props)
.filter(isProperty)
.forEach(name => dom[name] = element.props[name]);
element.props.children?.forEach(child => render(child, dom));
container.appendChild(dom);
}
export default { render };Problem: this naïve implementation cannot be interrupted; long JavaScript execution blocks rendering and causes jank.
First Optimization – Work Loop with requestIdleCallback
Introduce a global nextUnitOfWork and schedule work during idle periods, allowing the browser to pause and resume rendering.
let nextUnitOfWork = null;
function workLoop(deadline) {
let shouldYield = false;
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
shouldYield = deadline.timeRemaining() < 1;
}
requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);
function performUnitOfWork(fiber) {
if (!fiber.dom) fiber.dom = createDOM(fiber);
if (fiber.parent) fiber.parent.dom.appendChild(fiber.dom);
const elements = fiber.props.children;
let prevSibling = null;
elements.forEach((child, i) => {
const newFiber = { parent: fiber, props: child.props, type: child.type, dom: null, sibling: null };
if (i === 0) fiber.child = newFiber; else prevSibling.sibling = newFiber;
prevSibling = newFiber;
});
if (fiber.child) return fiber.child;
let next = fiber;
while (next) {
if (next.sibling) return next.sibling;
next = next.parent;
}
return null;
}Issue: each fiber is still appended to the real DOM immediately, causing frequent re‑flows and a progressive “building‑up” visual effect.
Second Optimization – Batch DOM Commit
Collect all DOM nodes first, then commit them in one batch after the work loop finishes.
function workLoop(deadline) {
while (nextUnitOfWork && !shouldYield) { /* same as before */ }
if (!nextUnitOfWork && wipRoot) commitRoot();
requestIdleCallback(workLoop);
}
function commitRoot() {
commitWork(wipRoot.child);
wipRoot = null;
}
function commitWork(fiber) {
if (!fiber) return;
const parentDom = fiber.parent.dom;
parentDom.appendChild(fiber.dom);
commitWork(fiber.child);
commitWork(fiber.sibling);
}Remaining problem: the whole tree is still re‑rendered on every update, leading to high overhead.
Third Optimization – Diff Algorithm
Introduce a reconciliation step that compares the new fiber tree with the previous one, generating PLACEMENT, UPDATE, and DELETION effect tags so that only changed parts are committed.
function reconcileChildren(wipFiber, elements) {
let index = 0;
let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
let prevSibling = null;
while (index < elements.length || oldFiber) {
const element = elements[index];
const sameType = oldFiber && element && oldFiber.type === element.type;
let newFiber = null;
if (sameType) {
newFiber = { type: oldFiber.type, props: element.props, dom: oldFiber.dom, parent: wipFiber, alternate: oldFiber, effectTag: 'UPDATE' };
} else if (element && !sameType) {
newFiber = { type: element.type, props: element.props, dom: null, parent: wipFiber, alternate: null, effectTag: 'PLACEMENT' };
} else if (oldFiber && !sameType) {
oldFiber.effectTag = 'DELETION';
deletions.push(oldFiber);
}
if (oldFiber) oldFiber = oldFiber.sibling;
if (index === 0) wipFiber.child = newFiber; else if (newFiber) prevSibling.sibling = newFiber;
prevSibling = newFiber;
index++;
}
}
function commitRoot() {
deletions.forEach(commitWork);
commitWork(wipRoot.child);
currentRoot = wipRoot;
wipRoot = null;
}Conclusion
Fiber’s core contribution is an interruptible, priority‑driven rendering pipeline that prevents long JavaScript tasks from blocking the UI. Real implementations also include sophisticated task scheduling, fine‑grained lifecycle control, and a diff algorithm to minimize DOM mutations.
Further exploration can cover priority queues, concurrent mode, and advanced hooks.
Goodme Frontend Team
Regularly sharing the team's insights and expertise in the frontend field
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
