With so many other candidates, usually the performance bottleneck for javascript is not the responsiveness of setTimeout
. Highly asynchronous code is, however, prone to many bugs, a huge pain to test and debug, and may unexpectedly break for no apparent reason. A partial solution to the problem would be to roll your wrapper to the scheduler. In fact, you may want to abstract the concept of time completely from your application.
Out of necessity
The number one reason to embark on this journey is to avoid firefox/chrome sneakily changing the behavior of functions, ie. lengthening short setTimeout
s and setInterval
to be at least 1s long.
If the only time sensitive interaction of a tab with the outer world is the time frame while the user is actively looking at your application then this works just fine. For example it’s not a big deal to delay a real time chat notification by a second, and an animation can look at it’s Date.now()
watch and jump to the frame it was supposed to be. If however you are dealing with things like audio, or in codebender’s case, communication with hardware, 1s delays can, not only kill performance, but render your application completely useless.
Below is an example of a method to get around this by taking advantage of the asynchronous behavior of window.postMessage
and using it to make a loopback asynchronous communication pipe through the DOM:
- All
setTimeout
calls with timeout larger than 1000ms use the proper mechanism provided by the platform. - All calls to
setTimeout
with a timeout less than 1000ms send the delayed function to ourselves, along with the timestamp of the expected time when the event is to be called. - When receive a message that corresponds to a scheduled event is received, the scheduled time the scheduled time is checked. If it has expired, the function in question is run immediately. If not it is pushed again into the loopback channel.
Simple!
In practice some extra trivialities have to be dealt with so I provide a full implementation that works well with browserify. If you test your code on node, or some of your code may not run in a browser you need to check that the custom scheduler’s dependencies are in place:
if (!(global.postMessage && global.addEventListener)) {
scheduler.require('./path/to/scheduler.js');
}
and scheduler.js itself:
var i = 0;
var timeouts = {};
var messageName = "setImmediate" + new Date().getTime();
var oldSetTimeout = setTimeout;
var oldClearTimeout = clearTimeout;
/**
* Make a timeout that will always do the right thing. If the
* timeout is >1000 then the normal setTimeout is
* called. Otherwise we will keep throwing it on top of the
* queue.
* @param {Function} fn a function to be called
* @param {Integer} to the timeout
* @returns {Integer} The id that will be cleared
*/
var globalIndex = 0;
function newSetTimeout (fn, to) {
if (to >= 1000) {
return oldSetTimeout(fn, to);
}
var expectedTime = Date.now() + (to || 0),
index = globalIndex++;
timeouts[index] = fn;
postpone(fn, index, expectedTime);
return index;
}
/**
* Try clearing it as an immediate. If that fails clear it as a
* normal timeout.
* @param {Integer} i the timeout/immediate index
*/
function newClearTimeout (i) {
if (i in timeouts) {
delete timeouts[i];
return;
}
oldClearTimeout(i);
}
/**
* This is basically the setImmediate. You can force the index
* you want in case you are reusing it.
* @param {Function} fn the function to be called.
* @param {Integer} forceIndex the index to be used.
* @returns {Integer} the index actually used.
*/
function postpone(fn, index, expectedTime) {
if (i > 100000000) // max queue size
i = 0;
if (timeouts[index] !== fn) return;
global.postMessage({ type: messageName,
id: index,
expectedTime: expectedTime }, "*");
}
function receive(ev) {
if (ev.source !== window)
return;
var data = ev.data;
if (data && data instanceof Object && data.type === messageName) {
ev.stopPropagation();
var id = data.id,
fn = timeouts[id],
expectedTime = data.expectedTime;
if (!fn) return;
if (Date.now() < expectedTime) {
postpone(fn, id, expectedTime);
return;
}
delete timeouts[id];
fn();
}
}
function clear(id) {
delete timeouts[id];
}
global.addEventListener("message", receive, true);
module.exports.setTimeout = newSetTimeout;
module.exports.clearTimeout = newClearTimeout;
Note: Normal setTimeout
can take more than two parameters in order to pass them to the function. I always use closures to accomplish the same effect so I didn’t bother implementing that, but it’s trivial to modify the above example to make that happen.
Turning an obstacle into a stepping stone
Now that the main problem is solved here is a nice little trick that takes advantage of the browser behavior we just circumvented, enabling you to see if a user is currently looking at the particular tab where this code runs:
function currentTabFocused(cb) {
var callDate = Date.now()
setTimeout(function () {
cb(Date.now() - callDate < 1000);
})
}
To test it out
setTimeout(function () {
currentTabFocused(function (focused) {
console.log(focused?"Tab is in focus":"Tab is out of focus");
})
;}, 2000);
When the demonstration code is executed you have 2 seconds to stay or leave the current tab. The message Tab is in focus
will appear if you didn’t switch tab, and Tab is out of focus
will appear if you did actually switch.
This differs from checking for element focus in that it yields fewer false positives (ie the function thinks the tab is out of focus when it’s actually selected) but it is less responsive. This way you can spot buried and forgotten tabs of your application that are taking up too many resources. It’s a good idea to pair this method with the above to avoid taking up too much processing power if you don’t necessarily need it.
Note that iframes are usually treated by the browser as unfocused tabs in this respect.
Testing and debugging
There are three parameters to take into account when testing in relation to the scheduler:
- How good insight one can have in the way a process runs
- How fast it is to run a full test suite
- How well we can model a real world system
The first is obvious: you have virtually no access to the internal state of the scheduler and therefore insight of the overall state of the application when a test fails can be limited.
The second point has to do with how long the tests run for. To test applications that contain timed events you want to be able to skip ahead in time when nothing is going on.
Finally, there is no formal guarantee that two timeouts expected to run at times t2 \< t1 will actually execute in that order. It is however unrealistic to account for events being triggered out of order when they are expected to be 30s apart. Using your own scheduler can allow you to employ such logic in your tests.
Here is a simple scheduler that will skip to the interesting part when nothing is going on and keep an explicit message queue that you may inspect. This scheduler also provides an require('scheduler.js').onAllDone
that you can implement, which will be called when there is nothing left to do and the application just waits for an event to happen. This way a test can be notified to fail before the timeout runs up if an asynchronous function finishes without calling the test’s done()
function.
It is a good idea to use the scheduler’s now()
function instead of javascript’s Date.now()
in your code to preserve some sanity in your timestamps:
module.exports = {
state: {
noDelay: false,
queue: {},
uniqueId: 1,
timeOffset: 0
},
now: function () {
return Date.now() + this.state.timeOffset;
},
fastForward: function (expectedNow) {
// expectedNow = offset + realNow
this.state.timeOffset = expectedNow - Date.now();
},
trigger: function (key) {
var self = this;
global.setTimeout(function () {
var item = self.state.queue[key];
if (!item) return;
self.fastForward(item.due);
item.callback();
});
},
idle: function () {
var queueKeys = Object.getOwnPropertyNames(this.state.queue),
self = this;
if (queueKeys.length == 0) {
if (this.onAllDone) this.onAllDone();
return;
}
if (queueKeys.length == 1) {
this.trigger(queueKeys[0]);
return;
}
// == Introduce logic for out of order events here ==
var minKey = queueKeys.reduce(function (k, mink) {
var item = self.state.queue[k],
min = self.state.queue[mink];
if (item && item.due < min.due) return item;
return mink;
});
this.trigger(minKey);
},
schedule: function (cb, to) {
var self = this, id = this.state.uniqueId++;
this.state.queue[id] = {
due: (to || 0) + this.now(),
id: id,
callback: function () {
cb();
self.clearTimeout(id);
self.idle();
}};
// If we are waiting for noone.
if (Object.getOwnPropertyNames(this.state.queue).length == 1) {
global.setTimeout(function () {
self.idle();
});
}
return id;
},
setTimeout: function (cb, to) {
if (this.state.noDelay) {
return this.schedule(cb, to);
}
return global.setTimeout(cb, to);
},
clearTimeout: function (id) {
if (this.state.noDelay) {
delete this.state.queue[id];
return;
}
global.clearTimeout(id);
},
setImmediate: function (cb) {
return this.setTimeout(cb);
},
clearImmediate: function (id) {
return this.clearTimeout(id);
}
};
Why not a package
I haven’t looked too much out there for existing packages that will do all the above. I know jasmine can do some of the above stuff but:
- I only use
describe
andit
from mocha, which hardly makes it a dependency. I wouldn’t want to learn and depend on a framework just to avoid maintaining less than 200 simple loc. Software breaks and you need to have direct access to fixing it when it does. - This code presented serves as an example and is very ad-hoc, but the underlying principles though are much more general. I encourage you edit it as much as needed to meet the needs of your individual project.
- Contrary to the apparent popular opinion of the javascript world, I think that 200 lines of code, or anything that can be expressed as such, is very far from qualifying as a standalone package.
Deep insightful conclusion
Keeping mutable state lying around is bad enough. Having to deal with hidden mutable state that may behave strangely, like a message queue that changes the assigned timeouts, makes shooting yourself in the foot a breeze.
Then again, I, like so many people write javascript every day, ignoring excellent alternatives, it makes one wonder, are our reasons for feeding this beast with our each line of code always rational or are we captives of ourselves?