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.
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.
, , , , or triggers reflow. Changing paint only properties background-color , box-shadow , border-radius triggers repaint but not reflow.
, , 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. A box-shadow transition adds paint work.
Then, the optimized version. It uses transform and opacity , which stay on the GPU for smoother motion.
You can switch between Unoptimized and Optimized below and press shuffle a few times. The difference is clear, especially as you add more squares. It is interesting to note however, that firefox seems to handle the unoptimized version better than chrome. This shows that the browser engine used to render the page plays a big role in performance issues, and it is always worth running performance tests across multiple browsers.
Shuffle Unoptimized code Optimized Code Squares: 121
Unoptimized Implementation
function BadSquaresComponent () { const [ positions , setPositions ] = useState (initialPositions); const [ isAnimating , setIsAnimating ] = useState ( false ); const shuffleSquares = () => { setIsAnimating ( true ); // Placeholder that generates random positions const newPositions = computeNewPositions (); setPositions (newPositions); setTimeout (() => setIsAnimating ( false ), 500 ); }; return ( < div > < div onClick = {shuffleSquares}> shuffle squares div > {positions. map (( pos , index ) => ( < div key = {index} style = {{ position: 'absolute' , top: `${ pos . top }px` , // Triggers reflow left: `${ pos . left }px` , // Triggers reflow boxShadow: isAnimating ? '0 14px 28px rgba(239,68,68,0.45)' : 'none' // Triggers Repaint }} > {index + 1 } div > ))} div > ); }
Optimized Implementation
function GoodSquaresComponent () { const [ positions , setPositions ] = useState (initialPositions); const [ isAnimating , setIsAnimating ] = useState ( false ); const shuffleSquares = () => { setIsAnimating ( true ); // Placeholder that generates random positions const newPositions = computeNewPositions (); setPositions (newPositions); setTimeout (() => setIsAnimating ( false ), 500 ); }; return ( < div > < div onClick = {shuffleSquares}> shuffle squares div > {positions. map (( pos , index ) => ( < div key = {index} style = {{ position: 'absolute' , // GPU accelerated transform: `translate(${ pos . left }px, ${ pos . top }px)` , opacity: isAnimating ? 0.7 : 1 // GPU accelerated }} > {index + 1 } div > ))} div > ); }
Monitoring performance
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. Below are screenshots of both versions:
The unoptimized code barely reached 21 FPS when shuffling fast. The optimized version stayed at 60 FPS without breaking a sweat.
Conclusion