JSFiddle - React, Tailwind, and code Playground

HTML

<canvas id="canvas"></canvas>
<p>
  <span id="counter_missed">0</span><span> missed frames.</span>
</p>

<script>
function animationInterval(ms, signal, callback) {
  // Prefer currentTime, as it'll better sync animtions queued in the 
  // same frame, but if it isn't supported, performance.now() is fine.
  const start = document.timeline ? document.timeline.currentTime : performance.now();

  // To avoid scheduling rAF in the following frame,
  // we need to have some safety reverse-delay.
  // The best would be to know the current monitors refresh-rate
  // or to force it, but we currently can't.
  // so we assume 240Hz max (above that, the delay might not be enough...)
  const magic_safe_delay = 1000 / 240;

  function frame(time) {
    if (signal.aborted) return;
    callback(time);
    scheduleFrame(time);
  }

  function scheduleFrame(time) {
    const elapsed = time - start;
    // floor because setTimeout is never too early
    const flooredElapsed = Math.floor(elapsed / ms) * ms;
    const targetNext = start + flooredElapsed + ms;
    // all this can probably be just simplified to
    // const targetNext = Math.floor(time + ms);
    const delay = targetNext - performance.now();
    setTimeout(() => requestAnimationFrame(frame), delay - magic_safe_delay);
  }

  scheduleFrame(start);
}
</script>

JavaScript

const FPS = 60; // set it to your own monitor's refresh-rate
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

let fired = false; // a simple flag to check we correctly fired at each animation frame
let i = 0, j = 0; // the x-axis position of our two rects
// A "pure" rAF loop
// Note that we start with this one
// because animationInterval always pushes its rAF callback at the end of
// the animation callbacks map (thanks to setTimeout)
anim = () => {
	// animationInterval didn't kick in during last frame
  if( fired === true ) {
  	counter_missed.textContent ++;
  }
	fired = true;
  
  // update the first rect's x-axis position
  i = (i + 1) % canvas.width;
  
  // we know we are the first in the animation callbacks map,
  // so we can clear the whole context
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  // draw the top rectangle (x, y, width, height)
  ctx.fillRect(i, 10, 50, 50);

  requestAnimationFrame(anim);
};
requestAnimationFrame(anim);

// Now animationInterval, which if it was trully "in sync with the display"
// and if the FPS value was higher or equal to the monitor's refresh-rate
// should fire just as many times as 'anim' does
const controller = new AbortController();
animationInterval(1000 / FPS, controller.signal, () => {
	// set the flag down, to let 'anim' know we kicked in
  fired = false;
  
  // update the second rect's x-axis position
  j = (j + 1) % canvas.width;
  
  // draw the bottom rectangle (x, y, width, height)
  ctx.fillRect(j, 80, 50, 50);
} );