Timing Issues Demo Experiment Run Experiment

Timing Issues

Jaap Murre

Functions that explore different approaches to measuring time, including ways to attain millisecond precision or better.


Timing the stimulus presentation is difficult in the browser because there are great differences between browsers and because the native JavaScript function for this purpose, setTimeout(), is itself very imprecise.

We find that on Chrome timing based on requestAnimationFrame (RAF() function in NeuroTask) gives acceptable results that are also locked to the framerate (FPS or frames per second). On Chrome near-millisecond precision can be achieved in some cases (with a 2000 ms presentation) but on other systems and browsers (notably Edge), much lower precision in the range +/- 8 ms should be expected. 

For time-critical applications you should always consult the event variable returned by the await() function and check for the actual time duration.

Towards millisecond precision

The script also contains other examples of timing based on the standard JavaScript function setTimout(). With these, under ideal circumstances, submillisecond precision can be achieved across browsers and monitor refresh rates. This approach does not use RAF(), but setTimeout() with short intervals such as 5 ms, which is the shortest this function can do on many browsers including Chrome. By repeatedly calling setTimeout() with a waitfor construct (basically await(5) without the overhead of the await() function), we build up the total duration. Then as we are less then 10 ms from the total duration (e.g., 2000 ms and we are at 1992), we set the time for the final time slice (here, 8 ms). This method approaches millisecond timing.

We can actually improve on this by executing the final time slice with a function (called busy() in the script) that carries out a nonsense operation until the time has passed. We use Math.random() because the optimizer cannot simplify that. By constantly checking with now() we can accurately time the moment to break out of the loop, thereby very closely aproaching the target duration. This approach taxes the CPU heavily but since it is only used from less then 10 ms, it is acceptable. 

When testing this approach, we found that on Firefox and Edge there is a near-constant overhoot of about 0.5 to 0.7 ms. By using a self-test, we determine this ‘drift’ and subtract it from the target duration (e.g., setting 1999.3 instead of 2000 ms). 

Finally, the first two or three timing trials in a loop are often a bit longer, probably because the optimizer is still figuring out the final simplications. By having a few startup trials that we throw away, we are only left with the smooth trials. 

With this ‘ideal’ approach, we can obtain precision in the order 0.25 ms or less and with a small standard deviation and min-max range.

If you are a registered user and signed in, you can here copy this script and its stimuli to your own account, where you can edit it and change it in any way you want.

It is absolutely free to register (no credit card info asked!). You can then instantly copy this experiment with one click and edit it, change its accompanying texts, its landing page, stimuli, etc. Invite your colleagues, friends, or students to check out your experiment. There is no limit on how many people can do your experiment, even with a free account.

The catch? There is no catch! Just keep in mind that with a free account, you cannot collect data. For teaching that is usually not a problem. For research, prepaid data collection (unlimited subjects) starts as low as €10.00 for a 10-day period.

function framerate() { // Is less reliable than framelength()
    var i = 0, t = now(), last = 0, old_frame;
    
    while (true) {
        RAF();
        if (i === 0) {
            last = now();
        } else {
            new_frame = now() - last;
            if (old_frame) {
                if (Math.abs(new_frame - old_frame) > 8) { // dropped frame
                    console.log(new_frame - old_frame);
                    return; // 
                }
            }            
            last = now();
            old_frame = new_frame;
        }
        if (now() - t >= 1000) {
            break;
        }
        ++i;
    }
    return i;
}

function framelength() {
    var i = 0, t = now(), last = 0, frames = 0, new_frame, total_time = 0;
    
    while (true) {
        RAF();
        ++i;
        if (i > 1) {
            new_frame = now() - last;
            if (i > 10) { // Above only measure 'good' frames
                if (Math.abs(new_frame - total_time/frames) < 1) {
                    total_time += new_frame;
                    ++frames;
                }
            } else { // Startup code collects initial statistics
                total_time += new_frame;
                ++frames;
            }
        }
        last = now();
        if (now() - t > 1000) {
            break;
        }
    }
    
    console.log("Frames and length: ",frames,
        (total_time/frames).toFixed(1),
        Math.round(1000/(total_time/frames))
    );
        
    return total_time/frames;
}


// for (var k = 0; k < 20; k++) {
//     window.___framelength___ = framelength();
//     window.___framerate___ = Math.round(1000/window.___framelength___);
// }
// console.log("Finished check");

function await2(timeout) {
    t1 = now();
    
    while (true) {
        RAF();
        if (now() - t1 >= timeout) {
            break;
        }
    }
}


function awaitframes(timeout) {
    var frames = timeout*window.___framerate___/1000; 
    for (i = 0; i < frames; i++)
    {                
        RAF();
    }
}

function awaitframes_best(timeout) {
    var frames = timeout*window.___framerate___/1000, t1 = now();
    for (i = 0; i < frames; i++)
    {         
        if (now() - t1 > timeout - 12) {
            if (now() - t1 <= timeout - 2.5) { // setTimeout has 5 ms minimum
                console.log("Patch: ",timeout - now() + t1);
                ms1(timeout - now() + t1); // Attempt to patch overshoot
            }
            break;
        }
        RAF();
    }
    if (now() - t1 < timeout - 2.5) {
        console.log("Patch 2: ", now() - t1," -> ",timeout - now() + t1);
        // ms1(timeout - now() + t1); // Patch undershoot   
        ms2a(timeout - now() + t1); // Patch undershoot   
        // ms2() works better than ms1() because when the browser is very busy,
        // e.g., Edge right after start-up, setTimeout() tens to vershoot a lot,
        // in the order of 15 ms on say 65 ms (so, it will do 80 ms)
        // ms2a() is a slight improvement of ms2()
    }
}

function awaitframes2(timeout) {
    var frames = timeout/window.___framelength___; 
    for (i = 0; i < frames; i++)
    {                
        RAF();
    }
}

function ms1(n) {
    waitfor() {
        setTimeout(resume,n);
    }
}

function ms2(n) {
    var t, t1 = now(), t2, f, frames = [];
    
    while (true) {
        ms1(5);
        t = now();
        f = t - t2;
        frames.push(f);
        t2 = t;
        if (now() - t1 > n - 2.5) {
            break;
        }
    }
    
    return frames;
}

// This one is probably the best one, but not synced with RAF frames
// There may still be use for very short timings with sound etc.
// This function gives decent results from 10 ms upwards
function ms2a(n) {
    var t, t1 = now(), t2, f, frames = [];
    
    while (true) {
        ms1(5);
        t = now();
        f = t - t2;
        frames.push(f);
        t2 = t;
        if (now() - t1 > n - 10) {
            ms1(n - now() + t1 - 1); // Try to get the last one exactly right
            // Subtract 1 ms because setTimeout() tends to overshoot 1 ms,
            // which may be a rounding issue as setTimeout works with integer
            // milliseconds
            break;
        }
    }
    
    return frames;
}

function ms2b(n) {
    var t, t1 = now(), t2, f, frames = [];
    
    while (true) {
        ms1(10);
        t = now();
        f = t - t2;
        frames.push(f);
        t2 = t;
        if (now() - t1 > n - 15) {
            ms1(n - now() + t1 - 1); // Try to get the last one exactly right
            // Subtract 1 ms because setTimeout() tends to overshoot 1 ms
            break;
        }
    }
    
    return frames;
}

function ms3(n,slice) {
    slice = slice || 10;
    var t, t1 = now(), t2, f, frames = [];
    
    while (true) {
        ms1(slice);
        t = now();
        f = t - t2;
        frames.push(f);
        t2 = t;
        if (now() - t1 > n - 4) {
            break;
        }
    }
    
    return frames;
}

// busy() is better than busy2()
function busy(n) { // Keep the CPU busy for n ms. Use sparingly!
    var t = now(), dummy;
    
    while (now() - t < n) {
        dummy = Math.random(); // Cannot be optimized away
    } 
}

function busy2(n) { // Keep the CPU busy for n ms. Use sparingly!
    var t = +new Date(), dummy;
    
    while (+new Date() - t < n) {
        dummy = Math.random(); // Cannot be optimized away
    } 
}

function ms4(n) {
    var t, t1 = now(), t2, f;
    
    while (true) {
        ms1(5);
        t = now();
        f = t - t2;
        if (f > 10) {
            console.log("large overshoot: ",f); // often f >> 5ms
        }
        t2 = t;
        if (now() - t1 >= n - 10) {
            busy(n - now() + t1); // Try to get the last one exactly right
            break;
        }
    }
}

function timing_self_test(f,duration,trials,startup_trials) {
    
    duration = duration || 100;
    trials = trials || 20;
    startup_trials = startup_trials || 3;
    
    var k, T1, frame, total = 0, frames = [];
    for (k = 0; k < trials + startup_trials; k++) {
        T1 = now();
        if (k >= startup_trials) {
            f(duration);
            frame = now() - T1;
            total += frame;
            frames.push(frame);
        } else {
            f(duration); // Run a few times to get rid op optimizer not yet ready
        }
    }
    
    return {
        frames: frames,
        mean: total/trials,
        drift: total/trials - duration,
        min: stat.min(frames),
        max: stat.max(frames),
        stddev: stat.stddev(frames)
    }
}



// Be sure to check Use Sound under Options below!
// sound.preload('ms.mp3','silent_ms');
// sound.await('preloading_completed');
// function ms_sound(n) {
//     for (var i = 0; i < n/145; i++) {
//         sound.play('silent_ms');
//         await('soundended');        
//     }
// }

// The following is now done automatically
// window.___framelength___ = framelength();
// window.___framerate___ = Math.round(1000/window.___framelength___);

var T1, T2, total = 0;

console.log("Starting");

var trials = 20, startup_trials = 2; // To get the JS optimizer to 'settle'

console.log("Starting self test");
var accuracy = timing_self_test(ms4,100);
console.log("Timing self test: ",accuracy);
console.log("Self test completed");

for (k = 0; k < trials + startup_trials; k++) {
    T1 = now();
    // await2(2000); // Mean over 20: 2015.602
    // await2(1996); // Mean over 20: 1999.759
    // awaitframes(2000); // Mean over 20: 1999.764 THIS IS MY CHOICE
    // awaitframes2(2000); // Mean over 20: 2017.2624999919208
    // awaitframes_best(2000);
    // await(2000); // await() now behaves like awaitframes()
    // hold(2000); // Overshoots a bit: 2000.97
    // sleep(2000); // Similar to await2(), overshoots but better with 1996
    // ms1(2000); // Slightly worse than hold(): 2001.22
    // var f = ms2(2000); // Mean is OK but large range +- 2.5 ms: 2000.21
    // console.log("Frames: ",f); // setTimeout has a minimum of 5 ms
    //ms2a(2000); // Excellent: about 2000 +/- 0.5 on three browsers with range -2 to 2
    // MAYBE ms2a() is the beste one
    // ms2b(2000);
    // ms3(2000,50); // Longer slices give worse results because setTimeout is not precise
    // ms_sound(2000); // Pretty consistent but minimum play time is way too high: 2000/145
    // busy(10); // Excellent precision: about 0.1 ms
    // busy2(10); // Worse than busy(): about .25 ms with a much larger variance
    if (k >= startup_trials) {
        ms4(500 - accuracy.drift); // Now below 0.25 ms precision, sometimes better on Chrome
                   // 0.5 on FF (which limits precision of performance.now()),
                   // and this also limits the self-measurement precision to 0.5 ms
                   // Edige overshoots consistently by 0.7 or so 
        T2 = now() - T1;
        total += T2;
        console.log("Time:",T2);
    } else {
        ms4(500); // Run a few times to get rid op optimizer not yet ready
        console.log("Startup: ",now() - T1);    
    }
}

console.log("Mean: ",total/trials);

You can download the files as follows: Click on the file (link) and then right-click and choose Save as... from the menu. Some media files (e.g., sound) will have a download button for this purpose.