The following is another version of Mike Bostock's solution and inspired by @hughes' comment to @kashesandr's answer. It makes a single callback upon transition's end.
Given a drop function...
function drop(n, args, callback) {
    for (var i = 0; i < args.length - n; ++i) args[i] = args[i + n];
    args.length = args.length - n;
    callback.apply(this, args);
}
... we can extend d3 like so:
d3.transition.prototype.end = function(callback, delayIfEmpty) {
    var f = callback, 
        delay = delayIfEmpty,
        transition = this;
    drop(2, arguments, function() {
        var args = arguments;
        if (!transition.size() && (delay || delay === 0)) { // if empty
            d3.timer(function() {
                f.apply(transition, args);
                return true;
            }, typeof(delay) === "number" ? delay : 0);
        } else {                                            // else Mike Bostock's routine
            var n = 0; 
            transition.each(function() { ++n; }) 
                .each("end", function() { 
                    if (!--n) f.apply(transition, args); 
                });
        }
    });
    return transition;
}
As a JSFiddle.
Use transition.end(callback[, delayIfEmpty[, arguments...]]):
transition.end(function() {
    console.log("all done");
});
... or with an optional delay if transition is empty:
transition.end(function() {
    console.log("all done");
}, 1000);
... or with optional callback arguments:
transition.end(function(x) {
    console.log("all done " + x);
}, 1000, "with callback arguments");
d3.transition.end will apply the passed callback even with an empty transition if the number of milliseconds is specified or if the second argument is truthy. This will also forward any additional arguments to the callback (and only those arguments). Importantly, this will not by default apply the callback if transition is empty, which is probably a safer assumption in such a case.