93

I need to create a simple but accurate timer.

This is my code:

var seconds = 0;
setInterval(function() {
timer.innerHTML = seconds++;
}, 1000);

After exactly 3600 seconds, it prints about 3500 seconds.

  • Why is it not accurate?

  • How can I create an accurate timer?

Mark Amery
  • 110,735
  • 57
  • 354
  • 402
xRobot
  • 22,700
  • 56
  • 163
  • 281
  • Changing timer.innerHTML isn't always **instant**. How did you measure the "right" time? – cbr Apr 30 '15 at 15:34
  • You're at the mercy of the browser (and the host operating system). You can use `setTimeout()` instead of `setInterval()`, keeping track of the time drift and adjusting each sleep period accordingly. – Pointy Apr 30 '15 at 15:35
  • You might try to use setTimeout instead of setInterval using various callbacks.. Perhaps another way is using the time object instead? maybe the difference between the rendered moment and the current time is more precise (not sure, though). – briosheje Apr 30 '15 at 15:37
  • If you can't use the `Date` object, you can also rely on the `setTimeout()` function, and re-sync with the updated `time()` of the server via ajax every other N seconds. – Andres SK May 30 '17 at 23:25
  • 2
    Funny how it’s less time and not more though. I could completely understand it taking longer, but less is very odd. – Simon_Weaver Aug 21 '18 at 22:46

11 Answers11

166

Why is it not accurate?

Because you are using setTimeout() or setInterval(). They cannot be trusted, there are no accuracy guarantees for them. They are allowed to lag arbitrarily, and they do not keep a constant pace but tend to drift (as you have observed).

How can I create an accurate timer?

Use the Date object instead to get the (millisecond-)accurate, current time. Then base your logic on the current time value, instead of counting how often your callback has been executed.

For a simple timer or clock, keep track of the time difference explicitly:

var start = Date.now();
setInterval(function() {
    var delta = Date.now() - start; // milliseconds elapsed since start
    …
    output(Math.floor(delta / 1000)); // in seconds
    // alternatively just show wall clock time:
    output(new Date().toUTCString());
}, 1000); // update about every second

Now, that has the problem of possibly jumping values. When the interval lags a bit and executes your callback after 990, 1993, 2996, 3999, 5002 milliseconds, you will see the second count 0, 1, 2, 3, 5 (!). So it would be advisable to update more often, like about every 100ms, to avoid such jumps.

However, sometimes you really need a steady interval executing your callbacks without drifting. This requires a bit more advantaged strategy (and code), though it pays out well (and registers less timeouts). Those are known as self-adjusting timers. Here the exact delay for each of the repeated timeouts is adapted to the actually elapsed time, compared to the expected intervals:

var interval = 1000; // ms
var expected = Date.now() + interval;
setTimeout(step, interval);
function step() {
    var dt = Date.now() - expected; // the drift (positive for overshooting)
    if (dt > interval) {
        // something really bad happened. Maybe the browser (tab) was inactive?
        // possibly special handling to avoid futile "catch up" run
    }
    … // do what is to be done

    expected += interval;
    setTimeout(step, Math.max(0, interval - dt)); // take into account drift
}
Community
  • 1
  • 1
Bergi
  • 513,640
  • 108
  • 821
  • 1,164
  • 6
    A little explanation for the second timer code would be really helpful – Hassaan Mar 02 '16 at 12:23
  • @M.Hassaan: I've added a sentence, but I'm not really sure what you are looking for. The code uses quite descriptive names and is extensively commented already. Does any particular part need further clarification? – Bergi Mar 02 '16 at 14:55
  • It took quite a while to understand, but once I did it seemed pretty simple. That sentence was a welcome addition! – Hassaan Mar 02 '16 at 20:13
  • 1
    It depends a lot on what your timer is doing. Most of the time, there's a better way than executing `step` multiple times at once. If you use the difference strategy (instead of a counter) - which you should regardless whether the timer is self-adjusting or not - you will only need a single step and it catches up automatically. – Bergi Mar 03 '16 at 09:03
  • @Bergi your awesome answers make it impossible for us regular people to compete haha – nem035 Aug 26 '16 at 01:05
  • 1
    @nem035 Oh well, there's enough questions that I haven't answered yet :-) – Bergi Aug 26 '16 at 01:22
  • Is there a way to stop the second timer once it starts (much like how `clearInterval()` would work)? – Leon Williams Jun 02 '17 at 19:05
  • Actually... [I think I figured it out](https://stackoverflow.com/a/44337628/5675729). – Leon Williams Jun 02 '17 at 21:29
  • Damn I hate those variables named like `dt`. Naming is important. – Adam Pietrasiak Jun 19 '17 at 12:18
  • @AdamPietrasiak You can write it out as `deltaTime`, but I'd say it's a pretty standard abbreviation. – Bergi Jun 19 '17 at 16:35
  • What do you suggest doing if `dt > interval`? – Joshua Michael Waggoner Apr 18 '18 at 17:21
  • Thanks so much btw! – Joshua Michael Waggoner Apr 18 '18 at 17:21
  • 1
    @JoshuaMichaelCalafell Depends on your use case what to do when the step is seriously delayed. If you do nothing, it tries to catch up by firing as many steps as necessary as quickly as possible, which might actually be detrimental for animations etc. You could do `expected += dt` (same as `expected = Date.now()`) or `expected += interval * Math.floor(dt / interval)` which would just skip the missed steps - probably fine for everything that works with a delta. If you are somehow counting the steps, you might need to adjust that then. – Bergi Apr 18 '18 at 19:33
  • In the self-adjusting timer's `step` function, shouldn't the calculation of `dt` take place only after doing what is to be done? The code in your example seems to ignore the time taken by the things to be done. – Flux Nov 28 '18 at 14:44
  • @Flux True, I didn't expect the code to be long running. It also might depend on the `dt` variable itself. It shouldn't matter a lot since the timer is still self-adjusting :-) If you really have problems with this, I'd recommend to move the `setTimeout` call before the computation. – Bergi Nov 28 '18 at 14:53
  • 1
    The timer as written above will almost always linger behind the expected time by a small amount. This is fine if you just need accurate intervals, but if you are timing relative to other events then you will always have this small delay. To correct that, you can also keep track of the drift history and add a secondary adjustment which centers the drift around the target time. For example, if you're getting a drift of 0 to 30ms, this would adjust it to -15 to +15ms. I did this with a rolling median to account for variance over time. Taking just 10 samples makes a reasonable difference. – Blorf Mar 19 '19 at 17:08
  • @Blorf Good point. Can you post your code that does this as an answer, so that I can upvote? – Bergi Mar 19 '19 at 17:24
  • @Bergi Done. Hope that helps someone. – Blorf Mar 21 '19 at 19:03
  • In your answer you say *When the interval lags a bit and executes your callback after `990`...* while the example uses a timeout of `1000`. But as far as I know the timeout is guaranteed to always be at least what you provided. So is this even possible for the callback to be called already after `990` ms which also wouldn't be a considered lag? – trixn Apr 30 '20 at 14:07
  • @trixn Good catch, I don't know if there's such a guarantee. But even if there is, the jump could still occur - it just take a bit longer until it gets into such a situation by lagging, where it is scheduled a few milliseconds before the 1s interval. – Bergi Apr 30 '20 at 14:43
25

I'ma just build on Bergi's answer (specifically the second part) a little bit because I really liked the way it was done, but I want the option to stop the timer once it starts (like clearInterval() almost). Sooo... I've wrapped it up into a constructor function so we can do 'objecty' things with it.

1. Constructor

Alright, so you copy/paste that...

/**
 * Self-adjusting interval to account for drifting
 * 
 * @param {function} workFunc  Callback containing the work to be done
 *                             for each interval
 * @param {int}      interval  Interval speed (in milliseconds) - This 
 * @param {function} errorFunc (Optional) Callback to run if the drift
 *                             exceeds interval
 */
function AdjustingInterval(workFunc, interval, errorFunc) {
    var that = this;
    var expected, timeout;
    this.interval = interval;

    this.start = function() {
        expected = Date.now() + this.interval;
        timeout = setTimeout(step, this.interval);
    }

    this.stop = function() {
        clearTimeout(timeout);
    }

    function step() {
        var drift = Date.now() - expected;
        if (drift > that.interval) {
            // You could have some default stuff here too...
            if (errorFunc) errorFunc();
        }
        workFunc();
        expected += that.interval;
        timeout = setTimeout(step, Math.max(0, that.interval-drift));
    }
}

2. Instantiate

Tell it what to do and all that...

// For testing purposes, we'll just increment
// this and send it out to the console.
var justSomeNumber = 0;

// Define the work to be done
var doWork = function() {
    console.log(++justSomeNumber);
};

// Define what to do if something goes wrong
var doError = function() {
    console.warn('The drift exceeded the interval.');
};

// (The third argument is optional)
var ticker = new AdjustingInterval(doWork, 1000, doError);

3. Then do... stuff

// You can start or stop your timer at will
ticker.start();
ticker.stop();

// You can also change the interval while it's in progress
ticker.interval = 99;

I mean, it works for me anyway. If there's a better way, lemme know.

Leon Williams
  • 466
  • 1
  • 6
  • 15
  • If you are open to using RxJS you can make use of Observable.timer() and various operators to implement a similar solution: https://stackblitz.com/edit/session-expire-warning-timer?file=src%2Fapp%2Fsession-timeout%2Fsession-timeout.component.ts – tedw Mar 25 '19 at 17:27
12

Most of the timers in the answers here will linger behind the expected time because they set the "expected" value to the ideal and only account for the delay that the browser introduced before that point. This is fine if you just need accurate intervals, but if you are timing relative to other events then you will (nearly) always have this delay.

To correct it, you can keep track of the drift history and use it to predict future drift. By adding a secondary adjustment with this preemptive correction, the variance in the drift centers around the target time. For example, if you're always getting a drift of 20 to 40ms, this adjustment would shift it to -10 to +10ms around the target time.

Building on Bergi's answer, I've used a rolling median for my prediction algorithm. Taking just 10 samples with this method makes a reasonable difference.

var interval = 200; // ms
var expected = Date.now() + interval;

var drift_history = [];
var drift_history_samples = 10;
var drift_correction = 0;

function calc_drift(arr){
  // Calculate drift correction.

  /*
  In this example I've used a simple median.
  You can use other methods, but it's important not to use an average. 
  If the user switches tabs and back, an average would put far too much
  weight on the outlier.
  */

  var values = arr.concat(); // copy array so it isn't mutated
  
  values.sort(function(a,b){
    return a-b;
  });
  if(values.length ===0) return 0;
  var half = Math.floor(values.length / 2);
  if (values.length % 2) return values[half];
  var median = (values[half - 1] + values[half]) / 2.0;
  
  return median;
}

setTimeout(step, interval);
function step() {
  var dt = Date.now() - expected; // the drift (positive for overshooting)
  if (dt > interval) {
    // something really bad happened. Maybe the browser (tab) was inactive?
    // possibly special handling to avoid futile "catch up" run
  }
  // do what is to be done
       
  // don't update the history for exceptionally large values
  if (dt <= interval) {
    // sample drift amount to history after removing current correction
    // (add to remove because the correction is applied by subtraction)
      drift_history.push(dt + drift_correction);

    // predict new drift correction
    drift_correction = calc_drift(drift_history);

    // cap and refresh samples
    if (drift_history.length >= drift_history_samples) {
      drift_history.shift();
    }    
  }
   
  expected += interval;
  // take into account drift with prediction
  setTimeout(step, Math.max(0, interval - dt - drift_correction));
}
Blorf
  • 486
  • 3
  • 11
10

Bergi's answer pinpoints exactly why the timer from the question is not accurate. Here's my take on a simple JS timer with start, stop, reset and getTime methods:

class Timer {
  constructor () {
    this.isRunning = false;
    this.startTime = 0;
    this.overallTime = 0;
  }

  _getTimeElapsedSinceLastStart () {
    if (!this.startTime) {
      return 0;
    }
  
    return Date.now() - this.startTime;
  }

  start () {
    if (this.isRunning) {
      return console.error('Timer is already running');
    }

    this.isRunning = true;

    this.startTime = Date.now();
  }

  stop () {
    if (!this.isRunning) {
      return console.error('Timer is already stopped');
    }

    this.isRunning = false;

    this.overallTime = this.overallTime + this._getTimeElapsedSinceLastStart();
  }

  reset () {
    this.overallTime = 0;

    if (this.isRunning) {
      this.startTime = Date.now();
      return;
    }

    this.startTime = 0;
  }

  getTime () {
    if (!this.startTime) {
      return 0;
    }

    if (this.isRunning) {
      return this.overallTime + this._getTimeElapsedSinceLastStart();
    }

    return this.overallTime;
  }
}

const timer = new Timer();
timer.start();
setInterval(() => {
  const timeInSeconds = Math.round(timer.getTime() / 1000);
  document.getElementById('time').innerText = timeInSeconds;
}, 100)
<p>Elapsed time: <span id="time">0</span>s</p>

The snippet also includes a solution for your problem. So instead of incrementing seconds variable every 1000ms interval, we just start the timer and then every 100ms* we just read elapsed time from the timer and update the view accordingly.

* - makes it more accurate than 1000ms

To make your timer more accurate, you would have to round

Community
  • 1
  • 1
Tomasz Bubała
  • 1,800
  • 1
  • 10
  • 16
7

I agree with Bergi on using Date, but his solution was a bit of overkill for my use. I simply wanted my animated clock (digital and analog SVGs) to update on the second and not overrun or under run creating obvious jumps in the clock updates. Here is the snippet of code I put in my clock update functions:

    var milliseconds = now.getMilliseconds();
    var newTimeout = 1000 - milliseconds;
    this.timeoutVariable = setTimeout((function(thisObj) { return function() { thisObj.update(); } })(this), newTimeout);

It simply calculates the delta time to the next even second, and sets the timeout to that delta. This syncs all of my clock objects to the second. Hope this is helpful.

agent-p
  • 146
  • 1
  • 4
2

Here's a solution that pauses when the window is hidden, and can be cancelled with an abort controller.

function animationInterval(ms, signal, callback) {
  const start = document.timeline.currentTime;

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

  function scheduleFrame(time) {
    const elapsed = time - start;
    const roundedElapsed = Math.round(elapsed / ms) * ms;
    const targetNext = start + roundedElapsed + ms;
    const delay = targetNext - performance.now();
    setTimeout(() => requestAnimationFrame(frame), delay);
  }

  scheduleFrame(start);
}

Usage:

const controller = new AbortController();

// Create an animation callback every second:
animationInterval(1000, controller.signal, time => {
  console.log('tick!', time);
});

// And stop it sometime later:
controller.abort();
JaffaTheCake
  • 11,118
  • 3
  • 43
  • 52
  • 21st century solution! [Gist](https://gist.github.com/jakearchibald/cb03f15670817001b1157e62a076fe95) with detailed video explanation. Thanks Jake! – Moos Feb 17 '21 at 18:25
1

This is an old question but figured I'd share some code I use sometimes:

function Timer(func, delay, repeat, runAtStart)
{
    this.func = func;
    this.delay = delay;
    this.repeat = repeat || 0;
    this.runAtStart = runAtStart;

    this.count = 0;
    this.startTime = performance.now();

    if (this.runAtStart)
        this.tick();
    else
    {
        var _this = this;
        this.timeout = window.setTimeout( function(){ _this.tick(); }, this.delay);
    }
}
Timer.prototype.tick = function()
{
    this.func();
    this.count++;

    if (this.repeat === -1 || (this.repeat > 0 && this.count < this.repeat) )
    {
        var adjustedDelay = Math.max( 1, this.startTime + ( (this.count+(this.runAtStart ? 2 : 1)) * this.delay ) - performance.now() );
        var _this = this;
        this.timeout = window.setTimeout( function(){ _this.tick(); }, adjustedDelay);
    }
}
Timer.prototype.stop = function()
{
    window.clearTimeout(this.timeout);
}

Example:

time = 0;
this.gameTimer = new Timer( function() { time++; }, 1000, -1);

Self-corrects the setTimeout, can run it X number of times (-1 for infinite), can start running instantaneously, and has a counter if you ever need to see how many times the func() has been run. Comes in handy.

Edit: Note, this doesn't do any input checking (like if delay and repeat are the correct type. And you'd probably want to add some kind of get/set function if you wanted to get the count or change the repeat value.

V. Rubinetti
  • 658
  • 6
  • 16
  • I'm curious. Why `(this.count+(this.runAtStart ? 2 : 1)` it seems to make the first step take too long with a runAtStart = true and also seems to make first frame too long if not runAtStart = true. ? – Justin Vincent May 18 '19 at 18:09
  • I guess what I really mean to ask is, is there any reason that it can't just be like this? `var adjustedDelay = Math.max( 1, this.startTime + (this.count * this.delay ) - performance.now() );` – Justin Vincent May 18 '19 at 19:06
  • Honestly I don't remember. This code is pretty old at this point, and could probably be re-written a lot cleaner. Just wanted to copy/paste share to show a different way to do it than the other answers, with a specified `run N times` parameter, which I've found enormously useful (and also that you can do it with prototype if you need to). – V. Rubinetti May 24 '19 at 17:57
1

Inspired by Bergi's answer I created the following complete non drifting timer. What I wanted was a way to set a timer, stop it, and do this simply.

var perfectTimer = {                                                              // Set of functions designed to create nearly perfect timers that do not drift
    timers: {},                                                                     // An object of timers by ID
  nextID: 0,                                                                      // Next available timer reference ID
  set: (callback, interval) => {                                                  // Set a timer
    var expected = Date.now() + interval;                                         // Expected currect time when timeout fires
    var ID = perfectTimer.nextID++;                                               // Create reference to timer
    function step() {                                                             // Adjusts the timeout to account for any drift since last timeout
      callback();                                                                 // Call the callback
      var dt = Date.now() - expected;                                             // The drift (ms) (positive for overshooting) comparing the expected time to the current time
      expected += interval;                                                       // Set the next expected currect time when timeout fires
      perfectTimer.timers[ID] = setTimeout(step, Math.max(0, interval - dt));     // Take into account drift
    }
    perfectTimer.timers[ID] = setTimeout(step, interval);                         // Return reference to timer
    return ID;
  },
  clear: (ID) => {                                                                // Clear & delete a timer by ID reference
    if (perfectTimer.timers[ID] != undefined) {                                   // Preventing errors when trying to clear a timer that no longer exists
      console.log('clear timer:', ID);
      console.log('timers before:', perfectTimer.timers);
      clearTimeout(perfectTimer.timers[ID]);                                      // Clear timer
      delete perfectTimer.timers[ID];                                             // Delete timer reference
      console.log('timers after:', perfectTimer.timers);
    }
    }       
}




// Below are some tests
var timerOne = perfectTimer.set(() => {
    console.log(new Date().toString(), Date.now(), 'timerOne', timerOne);
}, 1000);
console.log(timerOne);
setTimeout(() => {
    perfectTimer.clear(timerOne);
}, 5000)

var timerTwo = perfectTimer.set(() => {
    console.log(new Date().toString(), Date.now(), 'timerTwo', timerTwo);
}, 1000);
console.log(timerTwo);

setTimeout(() => {
    perfectTimer.clear(timerTwo);
}, 8000)
0

Doesn't get much more accurate than this.

var seconds = new Date().getTime(), last = seconds,

intrvl = setInterval(function() {
    var now = new Date().getTime();

    if(now - last > 5){
        if(confirm("Delay registered, terminate?")){
            clearInterval(intrvl);
            return;
        }
    }

    last = now;
    timer.innerHTML = now - seconds;

}, 333);

As to why it is not accurate, I would guess that the machine is busy doing other things, slowing down a little on each iteration adds up, as you see.

php_nub_qq
  • 12,762
  • 17
  • 59
  • 123
  • 2
    Things get hilarious when user starts changing their OS time. – eithed Apr 30 '15 at 15:39
  • @eithedog well doesn't get any better than this. Actually it does, hold on! – php_nub_qq Apr 30 '15 at 15:40
  • Unless you want 2 second pauses and then the displayed number jumping from eg 10 to 12, make this update more frequently (say 100 ms instead of 1000) – James Apr 30 '15 at 15:42
  • @James well, if your machine freezes for 2 seconds, I don't see how updating more frequently is going to help. – php_nub_qq Apr 30 '15 at 15:43
  • 1
    I would remove the alert, because if i were using the timer, just for the sake of curiosity, I would move on and change the current system time, because it's like you're indirectly telling me "huh, what happens if you do that?!?!?!" http://imgs.xkcd.com/comics/the_difference.png – briosheje Apr 30 '15 at 15:50
  • My point is that you can increase the accuracy, especially if you are rounding to seconds, by reducing the timeout. [See fiddle](http://jsfiddle.net/xzdnabyn/) - see how the 1000ms "gets stuck"? – James Apr 30 '15 at 15:51
  • There, everything fixed. You all happy now? :D – php_nub_qq Apr 30 '15 at 16:07
0

One of my simplest implementations is down below. It can even survive page reloads. :-

Code pen: https://codepen.io/shivabhusal/pen/abvmgaV

$(function() {
  var TTimer = {
    startedTime: new Date(),
    restoredFromSession: false,
    started: false,
    minutes: 0,
    seconds: 0,
    
    tick: function tick() {
      // Since setInterval is not reliable in inactive windows/tabs we are using date diff.
      var diffInSeconds = Math.floor((new Date() - this.startedTime) / 1000);
      this.minutes = Math.floor(diffInSeconds / 60);
      this.seconds = diffInSeconds - this.minutes * 60;
      this.render();
      this.updateSession();
    },
    
    utilities: {
      pad: function pad(number) {
        return number < 10 ? '0' + number : number;
      }
    },
    
    container: function container() {
      return $(document);
    },
    
    render: function render() {
      this.container().find('#timer-minutes').text(this.utilities.pad(this.minutes));
      this.container().find('#timer-seconds').text(this.utilities.pad(this.seconds));

    },
    
    updateSession: function updateSession() {
      sessionStorage.setItem('timerStartedTime', this.startedTime);
    },
    
    clearSession: function clearSession() {
      sessionStorage.removeItem('timerStartedTime');
    },
    
    restoreFromSession: function restoreFromSession() {
      // Using sessionsStorage to make the timer persistent
      if (typeof Storage == "undefined") {
        console.log('No sessionStorage Support');
        return;
      }

      if (sessionStorage.getItem('timerStartedTime') !== null) {
        this.restoredFromSession = true;
        this.startedTime = new Date(sessionStorage.getItem('timerStartedTime'));
      }
    },
    
    start: function start() {
      this.restoreFromSession();
      this.stop();
      this.started = true;
      this.tick();
      this.timerId = setInterval(this.tick.bind(this), 1000);
    },
    
    stop: function stop() {
      this.started = false;
      clearInterval(this.timerId);
      this.render();
    }
  };

  TTimer.start();

});
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>

<h1>
  <span id="timer-minutes">00</span> :
  <span id="timer-seconds">00</span>

</h1>
illusionist
  • 7,996
  • 1
  • 46
  • 63
0

driftless is a drop-in replacement for setInterval that mitigates drift. Makes life easy, import the npm package, then use it like setInterval / setTimeout:

setDriftlessInterval(() => {
    this.counter--;
}, 1000);

setDriftlessInterval(() => {
    this.refreshBounds();
}, 20000);
Grant
  • 3,497
  • 25
  • 29
  • Thanks for the share. Unfortunately the timing is still all over the place in my use case. My issue is whole seconds off--not milliseconds. I'll have to look for something else – velkoon May 21 '21 at 18:34
  • @velkoon I have noticed this solution has trouble when navigating away from the tab on Chrome - however it rights itself when you navigate back pretty quickly. I think the `requestAnimationFrame` solution may be the one. Please let me know if you find a solution that is foolproof. – Grant May 22 '21 at 15:11