Inside a 16.67 Millisecond Frame
Understand how rendering works through a practical example.
tldr; go to the DEMO
Framework or vanilla JS, the browser plays by its own rules. Understanding them can be the difference between a janky mess and a smooth experience.
A Frame in 16.67 Milliseconds
JavaScript runs on a single thread and must yield control to the browser to get anything drawn.
Here is what happens during one frame:
- Scripting - The JavaScript engine runs your code.
- Style Calculation - The browser figures out which CSS rules apply and computes the final styles, resolving cascades, inheritance, and computed values.
- Reflow (also called layout) – Calculates geometry such as width, height, and position. A reflow can ripple through parent and child elements, making it costly.
- Repaint (also called paint) – Draws pixels for backgrounds, borders, text, and shadows. Complex visuals like gradients or shadows slow this step.
- Composite – Takes painted layers and draws them to the screen. This step is much cheaper than reflow or repaint.
Frame budget math: There are 1000 milliseconds in one second. Dividing by 60 frames per second gives 1000/60 = 16.67 milliseconds per frame. If the above steps take longer than this to execute, the browser will drop frames resulting in ‘jank’.
What matters in practice
Modern browsers handle simple pages easily, but once you add animations or many elements, performance limits appear. Knowing which properties trigger which steps can help you keep things fast.
- Scripting time is the time your JavaScript holds the main thread. Heavy work delays reflow and repaint.
- Changing properties like
top,left,width,height, ormargintriggers reflow. - Changing paint only properties
background-color,box-shadow,border-radiustriggers repaint but not reflow. - Changing
transformoropacityis usually handled by the GPU, skipping reflow and repaint.
The Use Case (tested on Chrome)
To demonstrate this, we will build a small demo with squares that can move and shuffle.
First, the unoptimized version. It uses top and left, which cause reflows on every frame.
Then, the optimized version, which uses transform and opacity.
You can tweak the number of squares and the shadow toggle to compare the examples. Also, there is a resize toggle to fit more squares on the screen, when the number grows very large.
Feel free to spam the shuffle button to see any difference between the two versions.
Findings
Modern browsers have become much better at handling rendering. In fact, when shadows are not toggled, the unoptimized code still performs very well unless we drastically increase the number of squares.
From the performance monitor, we can see that reflow is indeed triggered in the unoptimized version:
For the optimized version, however, reflow is completely skipped. Interestingly, the transitions remain smooth even when shadows are enabled:
Both screenshots capture what happens when repeatedly spamming the shuffle button over time. Of course, results may vary depending on your hardware. These were taken on a Mac mini M1.
A final note on layout: using position: absolute helps reduce rendering strain because each square is independent. In other words, changes to top and left only affect the square itself, not other elements on the page.
By contrast, moving elements that trigger full-page layout recalculations can significantly worsen reflow and repaint costs. For this demo, we’re keeping the focus on isolated elements to better highlight the rendering differences.
Implementations
The following snippets were stripped down to the bare minimum to demonstrate the differences in implementations. To see the full demo code, go to the repository.
Unoptimized Implementation
function BadSquaresComponent() {
const [squares, setSquares] = useState(initialSquares);
const [isAnimating, setIsAnimating] = useState(false);
const refDebounceShuffle = useRef<NodeJS.Timeout | null>(null);
const count = squares.length;
const onShuffle = () => {
// generate random indices
const shuffledSquares = shuffleIndices(count);
setSquares(shuffledSquares);
setIsAnimating(true);
if (refDebounceShuffle.current) {
clearTimeout(refDebounceShuffle.current);
}
refDebounceShuffle.current = setTimeout(() =>
setIsAnimating(false) ,DURATION_MS);
};
return (
<div>
<div onClick={onShuffle}> shuffle squares </div>
{squares.map((square) => (
<div
key={square}
style={{
position: 'absolute',
top: `${square.top}px`, // Triggers reflow
left: `${square.left}px`, // Triggers reflow
boxShadow: '0 14px 28px rgba(239,68,68,0.45)', // Triggers Repaint
transition: "top 250ms ease, left 250ms ease",
}}
>
{square + 1}
</div>
))}
</div>
);
}
Optimized Implementation
function GoodSquaresComponent() {
const [squares, setSquares] = useState(initialSquares);
const [isAnimating, setIsAnimating] = useState(false);
const refDebounceShuffle = useRef<NodeJS.Timeout | null>(null);
const count = indices.length;
const onShuffle = () => {
// generate random indices
const shuffledSquares = shuffleIndices(count);
setSquares(shuffledSquares);
setIsAnimating(true);
if (refDebounceShuffle.current) {
clearTimeout(refDebounceShuffle.current);
}
refDebounceShuffle.current = setTimeout(() =>
setIsAnimating(false) ,DURATION_MS);
};
return (
<div>
<div onClick={onShuffle}> shuffle squares </div>
{squares.map((square) => (
<div
key={index}
style={{
position: 'absolute',
// GPU accelerated
transform: `translate(${index.left}px, ${index.top}px)`,
opacity: isAnimating ? 0.9 : 1 // GPU accelerated
boxShadow: "0 14px 28px rgba(239,68,68,0.45)",
transition: "transform 250ms ease, opacity 250ms ease"
}}
>
{index + 1}
</div>
))}
</div>
);
}
Monitoring FPS
You can watch the animation and also use the FPS meter in DevTools (Cmd+Shift+P → “FPS meter”).
When running the demo, the meter shows the live frame rate.
A noticeable difference only appears when we max out the number of squares. The following screenshots were taken with the shadow disabled.
with unoptimized code:
with optimized code:
Conclusion
Smooth performance is not magic. It comes from understanding what the browser is doing and profiling against it. In fact, it turns out that for small animations, browsers have gotten so fast that the difference is negligible.