Because that's what requestAnimationFrame
does: it schedules a callback to fire at the next "painting frame", just before the actual painting to screen.
Here "frame" refers to an Event Loop iteration, not a visual one, and hereafter I'll keep using "Event Loop iteration" to make the distinction.
So if we take a look at the structure of an Event Loop iteration as described by the HTML specs, we can see that "run the animation frame callbacks" algorithm is called from inside the "update the rendering" algorithm.
This algorithm is responsible at step 2 of determining if the current Event Loop iteration is a painting one or not, by checking the "rendering opportunities" of each active Documents. If it isn't, then all the inner steps below are discarded, including our "run the animation frame callbacks".
This means that our requestAnimationFrame
scheduled callbacks will only get executed in a very special Event Loop Iteration: the next one with a rendering opportunity.
Specs don't describe precisely at which frequency this "painting frames" should occur, but basically most current vendors try to maintain 60Hz, while Chrome will make it cap to the active display's refresh rate. It is expected that Chrome behavior spreads to other vendors.
So what you describe is normal. If you want a simplified version of this, you can think of requestAnimationFrame( fn )
as setTimeout( fn, time_needed_until_the_next_painting_frame )
(with the here minor difference that timedout callbacks are executed at the beginning of the Event Loop iteration while animation frame callbacks are executed at the end).
Why has it been designed this way?
Well because most of the time we want to have the freshest information painted on the screen. So having these callbacks to fire right before the painting ensures that everything that should be painted is at its most recent position.
But this also means that indeed, we should not have too heavy operations occurring in there, at the risk of loosing a painting opportunity.
Now, I have to note that there is an ongoing proposal to include a requestPostAnimationFrame
, which would schedule callbacks to fire at the next "painting frame", just after the actual painting to screen.
With this method, you'd have the behavior you expected.
Unfortunately, that's still just a proposal, has not been included to the specs, and it's uncertain if it will ever be.
Though it is already implemented in Chrome, behind the "Experimental Web Platform features" flag, the best we can do to approach its behavior in normal browsers is to schedule a callback at the very beginning of the next Event Loop iteration.
Here is an example implementation I made for an other Q/A:
if (typeof requestPostAnimationFrame !== 'function') {
monkeyPatchRequestPostAnimationFrame();
}
requestAnimationFrame( animationFrameCallback );
requestPostAnimationFrame( postAnimationFrameCallback );
// monkey-patches requestPostAnimationFrame
//!\ Can not be called from inside a requestAnimationFrame callback
function monkeyPatchRequestPostAnimationFrame() {
console.warn('using a MessageEvent workaround');
const channel = new MessageChannel();
const callbacks = [];
let timestamp = 0;
let called = false;
channel.port2.onmessage = e => {
called = false;
const toCall = callbacks.slice();
callbacks.length = 0;
toCall.forEach(fn => {
try {
fn(timestamp);
} catch (e) {}
});
}
window.requestPostAnimationFrame = function(callback) {
if (typeof callback !== 'function') {
throw new TypeError('Argument 1 is not callable');
}
callbacks.push(callback);
if (!called) {
requestAnimationFrame((time) => {
timestamp = time;
channel.port1.postMessage('');
});
called = true;
}
};
}
// void loops, look at your dev-tools' timeline to see where each fires
function animationFrameCallback() {
requestAnimationFrame( animationFrameCallback );
}
function postAnimationFrameCallback() {
requestPostAnimationFrame( postAnimationFrameCallback )
}