Exact time of display: requestAnimationFrame usage and timeline

What you are experiencing is a Chrome bug (and even two).

Basically, when the pool of requestAnimationFrame callbacks is empty, they’ll call it directly at the end of the current event loop, without waiting for the actual painting frame as the specs require.

To workaround this bug, you can keep an ever-going requestAnimationFrame loop, but beware this will mark your document as “animated” and will trigger a bunch of side-effects on your page (like forcing a repaint at every screen refresh). So I’m not sure what you are doing, but it’s generally not a great idea to do this, and I would rather invite you to run this animation loop only when required.

let needed = true; // set to false when you don't need the rAF loop anymore

function item_display() {
  var before = performance.now();
  requestAnimationFrame(function(timest) {
    var r_start = performance.now();
    var r_ts = timest;
    console.log("before:", before);
    console.log("RAF callback start:", r_start);
    console.log("RAF stamp:", r_ts);
    console.log("before vs. RAF callback start:", r_start - before);
    console.log("before vs. RAF stamp:", r_ts - before);
    console.log("")
    setTimeout(item_display, Math.floor(Math.random() * (1000 - 500 + 1)) + 500);
  });
}
chromeWorkaroundLoop();
item_display();

function chromeWorkaroundLoop() {
  if (needed) {
    requestAnimationFrame(chromeWorkaroundLoop);
  }
};

Now, requestAnimationFrame callbacks fire before the next paint (actually in the same event loop), and the TimeStamp argument should represent the time after all main tasks and microtasks of the current frame were executed, before it’s starts its “update the rendering” sub-task (step 9 here).
[edit]: However it’s not really what browsers implement, see this Q/A for more details.

So it’s not the most precise you can have, and you are right that using performance.now() inside this callback should get you closer to the actual painting time.

Moreover when Chrome faces yet an other bug here, probably related to the first one, when they do set this rAF timeStamp to… I must admit I don’t know what… maybe the previous painting frame’s timeStamp.

(function() {
let raf_id,
  eventLoopReport = {
    id: 0,
    timeStamp: 0,
    now: 0
  },
  report = {
    nb_of_loops_between_call_and_start: -1,
    mouseClick_timeStamp: 0,
    calling_task: {
        eventLoop: null,
      now: 0
    },
    rAF_task: {
        eventLoop: null,
      now: 0,
      timeStamp: 0
    }
  };
  
startEventLoopCounter();
  
btn.onclick = triggerSingleFrame;


// increments eventLoop_id at every event loop
// (or at least every time our postMessage loop fires)
function startEventLoopCounter() {
  const channel = new MessageChannel()
  channel.port2.onmessage = e => {
    eventLoopReport.id ++;
    eventLoopReport.timeStamp = e.timeStamp;
    eventLoopReport.now = performance.now();
    channel.port1.postMessage('*');
  };
  channel.port1.postMessage('*');
}

function triggerSingleFrame(e) {
  // mouseClick Event should be generated at least the previous event loop, so its timeStamp should be in the past
    report.mouseClick_timeStamp = e.timeStamp;
    const report_calling = report.calling_task;
  report_calling.now = performance.now();
  report_calling.eventLoop = Object.assign({}, eventLoopReport);

    cancelAnimationFrame(raf_id);
  
    raf_id = requestAnimationFrame((raf_ts) => {
    const report_rAF = report.rAF_task;
        report_rAF.now = performance.now();
    report_rAF.timeStamp = raf_ts;
    report_rAF.eventLoop = Object.assign({}, eventLoopReport);
    report.nb_of_loops_between_call_and_start = report_rAF.eventLoop.id - report_calling.eventLoop.id;
    // this should always be positive
    report_el.textContent = "rAF.timeStamp - mouse_click.timeStamp: " +
            (report.rAF_task.timeStamp - report.mouseClick_timeStamp) + '\n\n' +
      // verbose
        JSON.stringify(report, null, 2) ;
  });
}
})();
<button id="btn">flash</button>
<div id="out"></div>
<pre id="report_el"></pre>

Once again, running an infinite rAF loop will fix this weird bug.

So one thing you might want to check is the maybe incoming requestPostAnimationFrame method.

You can access it in Chrome,1 after you enable “Experimental Web Platform features” in chrome:flags. This method if accepted by html standards will allow us to fire callbacks immediately after the paint operation occurred.

From there, you should be at the closest of the painting.

var needed = true;
function item_display() {
  var before = performance.now();
  requestAnimationFrame(function() {
    requestPostAnimationFrame(function() {
      var rPAF_now = performance.now();
      console.log("before vs. rPAF now:", rPAF_now - before);
      console.log("");
      setTimeout(item_display, Math.floor(Math.random() * (1000 - 500 + 1)) + 500);
    });
  });
}

if (typeof requestPostAnimationFrame === 'function') {
  chromeWorkaroundLoop();
  item_display();
} else {
  console.error("Your browser doesn't support 'requestPostAnimationFrame' method, be sure you enabled 'Experimental Web Platform features' in chrome:flags");
}

function chromeWorkaroundLoop() {
  if (needed) {
    requestAnimationFrame(chromeWorkaroundLoop);
  }
};

And for the browsers that do not yet implement this proposal, or if this proposal never does it through the specs, you can try to polyfill it using a MessageEvent, which should be the first thing to fire at the next event loop.

// polyfills requestPostAnimationFrame
// requestPostAnimationFrame polyfill
if (typeof requestPostAnimationFrame !== "function") {
  (() => {
    const channel = new MessageChannel();
    const callbacks = [];
    let timestamp = 0;
    let called = false;
    let scheduled = false; // to make it work from rAF
    let inRAF = false; // to make it work from rAF
    channel.port2.onmessage = e => {
      called = false;
      const toCall = callbacks.slice();
      callbacks.length = 0;
      toCall.forEach(fn => {
        try {
          fn(timestamp);
        } catch (e) {}
      });
    }
    // We need to overwrite rAF to let us know we are inside an rAF callback
    // as to avoid scheduling yet an other rAF, which would be one painting frame late
    // We could have hooked an infinite loop on rAF, but this means
    // forcing the document to be animated all the time
    // which is bad for perfs
    const rAF = globalThis.requestAnimationFrame;
    globalThis.requestAnimationFrame = function(...args) {
      if (!scheduled) {
        scheduled = true;
        rAF.call(globalThis, (time) => inRAF = time);
        globalThis.requestPostAnimationFrame(() => {
          scheduled = false;
          inRAF = false;
        });
      }
      rAF.apply(globalThis, args);
    };
    globalThis.requestPostAnimationFrame = function(callback) {
      if (typeof callback !== "function") {
        throw new TypeError("Argument 1 is not callable");
      }
      callbacks.push(callback);
      if (!called) {
        if (inRAF) {
          timestamp = inRAF;
          channel.port1.postMessage("");
        } else {
          requestAnimationFrame((time) => {
            timestamp = time;
            channel.port1.postMessage("");
          });
        }
        called = true;
      }
    };
  })();
}

var needed = true;

function item_display() {
  var before = performance.now();
  requestPostAnimationFrame(function() {
    var rPAF_now = performance.now();
    console.log("before vs. rPAF now:", rPAF_now - before);
    console.log("");
    setTimeout(item_display, Math.floor(Math.random() * (1000 - 500 + 1)) + 500);
  });
}


chromeWorkaroundLoop();
item_display();

function chromeWorkaroundLoop() {
  if (needed) {
    requestAnimationFrame(chromeWorkaroundLoop);
  }
};
  1. Turns out this feature has apparently been removed from Chrome experiments. Looking at the implementation issue I can’t find why, when, nor if they plan to still work on it.

Leave a Comment