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:

  1. Scripting - The JavaScript engine runs your code.
  2. Style Calculation - The browser figures out which CSS rules apply and computes the final styles, resolving cascades, inheritance, and computed values.
  3. Reflow (also called layout) – Calculates geometry such as width, height, and position. A reflow can ripple through parent and child elements, making it costly.
  4. Repaint (also called paint) – Draws pixels for backgrounds, borders, text, and shadows. Complex visuals like gradients or shadows slow this step.
  5. 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, or margin triggers reflow.
  • Changing paint only properties background-color, box-shadow, border-radius triggers repaint but not reflow.
  • Changing transform or opacity is 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:

reflow screenshot

For the optimized version, however, reflow is completely skipped. Interestingly, the transitions remain smooth even when shadows are enabled:

reflow screenshot

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:

slow FPS

with optimized code:

fast FPS

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.