Movatterモバイル変換


[0]ホーム

URL:


Read the Tea LeavesSoftware and other dark arts, by Nolan Lawson

31Aug

Why do browsers throttle JavaScript timers?

Posted August 31, 2025 by Nolan Lawson inperformance,Web.Tagged:.10 Comments

Even if you’ve been doing JavaScript for a while, you might be surprised to learn thatsetTimeout(0) is not reallysetTimeout(0). Instead, it could run 4 milliseconds later:

const start = performance.now()setTimeout(() => {  // Likely 4ms  console.log(performance.now() - start)}, 0)

Nearly a decade ago when I was on the Microsoft Edge team, it was explained to me that browsers did this to avoid “abuse.” I.e. there are a lot of websites out there that spamsetTimeout, so to avoid draining the user’s battery or blocking interactivity, browsers set a special “clamped” minimum of 4ms.

This also explains why some browsers would bump the throttling for devices on battery power (16ms in the case of legacy Edge), or throttle even more aggressively for background tabs (1 second in Chrome!).

One question always vexed me, though: ifsetTimeout was so abused, then why did browsers keep introducing new timers likesetImmediate (RIP), Promises, or even new fanciness likescheduler.postTask()? IfsetTimeout had to be nerfed, then wouldn’t these timers suffer the same fate eventually?

I wrote a long post about JavaScript timersback in 2018, but until recently I didn’t have a good reason to revisit this question. Then I was doing some work onfake-indexeddb, which is a pure-JavaScript implementation of the IndexedDB API, and this question reared its head. As it turns out, IndexedDB wants to auto-committransactions when there’s no outstanding work in the event loop – in other words, after all microtasks have finished, but before any tasks (can I cheekily say “macro-tasks”?) have started.

To accomplish this,fake-indexeddb was usingsetImmediate in Node.js (which shares some similarities with the legacy browser version) andsetTimeout in the browser. In Node,setImmediate is kind of perfect, because it runsafter microtasks but immediately before any other tasks, and without clamping. In the browser, though,setTimeout is pretty sub-optimal:in one benchmark, I was seeing Chrome take 4.8 seconds for something that only took 300 milliseconds in Node (a 16x slowdown!).

Looking out at the timer landscape in 2025, though, it wasn’t obvious what to choose. Some options included:

  • setImmediate – only supported in legacy Edge and IE, so that’s a no-go.
  • MessageChannel.postMessage – this is the technique used byafterframe.
  • window.postMessage – a nice idea, but kind of janky since it might interfere with other scripts on the page using the same API. This approach is used bythesetImmediate polyfill though.
  • scheduler.postTask – if you read no further, this was the winner. But let’s explain why!

To compare these options, I wrotea quick benchmark. A few important things about this benchmark:

  1. You have to run several iterations ofsetTimeout (and friends) to really suss out the clamping. Technically, per theHTML specification, the 4ms clamping is only supposed to kick in after asetTimeout has been nested (i.e. onesetTimeout calls another) 5 times.
  2. I didn’t test every possible combination of 1) battery vs plugged in, 2) monitor refresh rates, 3) background vs foreground tabs, etc., even though I know all of these things can affect the clamping. I have a life, and although it’s fun to don the lab coat and run some experiments, I don’t want to spend my entire Saturday doing that.

In any case, here are the numbers (in milliseconds, median of 101 iterations, on a 2021 16-inch MacBook Pro):

BrowsersetTimeoutMessageChannelwindowscheduler.postTask
Chrome 1394.20.050.030.00
Firefox 1424.720.020.010.01
Safari 18.426.730.520.05Not implemented

Note: this benchmark was tricky to write! When I first wrote it, I usedPromise.all to run all the timers simultaneously, but this seemed to defeat Safari’s nesting heuristics, and made Firefox’s fire inconsistently. Now the benchmark runs each timer independently.

Don’t worry about the precise numbers too much: the point is that Chrome and Firefox clampsetTimeout to 4ms, and the other three options are roughly equivalent. In Safari, interestingly,setTimeout is even more heavily throttled, andMessageChannel.postMessage is a tad slower thanwindow.postMessage (althoughwindow.postMessage is still janky for the reasons listed above).

This experiment answered my immediate question:fake-indexeddb should usescheduler.postTask (which I prefer for its ergonomics) and fall back to eitherMessageChannel.postMessage orwindow.postMessage. (I did experiment with differentpriorities forpostTask, but they all performedalmost identically. Forfake-indexeddb‘s use case, the default priority of'user-visible' seemed most appropriate, and that’s what the benchmark uses.)

None of this answered my original question, though: why exactlydo browsers bother to throttlesetTimeout if web developers can just usescheduler.postTask orMessageChannel instead? I asked my friendTodd Reifsteck, who was co-chair of theWeb Performance Working Group back when a lot of these discussions about“interventions” were underway.

He said that there were effectively two camps: one camp felt that timers needed to be throttled to protect web devs from themselves, whereas the other camp felt that developers should “measure their own silliness,” and that any subtle throttling heuristics would just cause confusion. In short, it was the standard tradeoff in designing performance APIs: “some APIs are quick but come with footguns.”

This jibes with my own intuitions on the topic. Browser interventions are usually put in place because web developers have either used too much of a good thing (e.g.setTimeout), or were blithely unaware of better options (thetouch listener controversy is a good example). In the end, the browser is a “user agent” acting on the user’s behalf, and the W3C’spriority of constituencies makes it clear that end-user needs always trump web developer needs.

That said, web developers oftendo want to do the right thing. (I consider this blog post an attempt in that direction.) We just don’t always have the tools to do it, so instead we grab whatever blunt instrument is nearby and start swinging. Giving us more control over tasks and scheduling could avoid the need to hammer away withsetTimeout and cause a mess that calls for an intervention.

My prediction is thatpostTask/postMessage will remain unthrottled for the time being. Out of Todd’s two “camps,” the very existence of theScheduler API, which offers a whole slew of fine-grained tools for task scheduling, seems to point toward the “pro-control” camp as the one currently steering the ship. Although Todd sees the API more as a compromise between the two groups: yes, it offers a lot of control, but it also aligns with the browser’s actual rendering pipeline rather than random timeouts.

The pessimist in me wonders, though, if the API could still be abused – e.g. by carelessly using theuser-blocking priority everywhere. Perhaps in the future, some enterprising browser vendor will put their foot more firmly on the throttle (so to speak) and discover that it causes websites to be snappier, more responsive, and less battery-draining. If that happens, then we may see another round of interventions. (Maybe we’ll need ascheduler2 API to dig ourselves out of that mess!)

I’m not involved much in web standards anymore and can only speculate. For the time being, I’ll just do what most web devs do: choose whatever API accomplishes my goals today, and hope that browsers don’t change too much in the future. As long as we’re careful and don’t introduce too much “silliness,” I don’t think that’s a lot to ask.

Thanks to Todd Reifsteck for feedback on a draft of this post.

Note: everything I said aboutsetTimeout could also be said aboutsetInterval. From the browser’s perspective, these are nearly the same APIs.

Note: for what it’s worth,fake-indexeddb is still falling back tosetTimeout rather thanMessageChannel orwindow.postMessage in Safari. Despite my benchmarks above, I was only able to getwindow.postMessage to outperform the other two infake-indexeddb‘s own benchmark – Safari seems to have some additional throttling forMessageChannel that my standalone benchmark couldn’t suss out. Andwindow.postMessage still seems error-prone to me, so I’m reluctant to use it. Here ismy benchmark for those curious.

10 responses to this post.

  1. jobleonard's avatar

    Posted by jobleonard onSeptember 1, 2025 at 2:13 AM

    I read that one argument for keepingsetTimeout throttled is not breaking legacy websites that rely on it. If that is true then I’m not too worried thatpostMessage will be throttled any time soon. It has been the defacto workaround for thesetTimeout throttling since 2010, thanks tothis blog post by David Baron. So the same argument should be made that throttlingpostMessage would also cause a huge performance regression for a lot of websites after fifteen years.

    Although it might not be a technique familiar to most JS programmers, itis used in a lot of popular frameworks (I believe Vue uses or used to use it for example).

    Last year I got a little nerd-sniped myself and tried to write the fastest async for loop (which, ironically, meant abandoning for…of in favor of a forEach due to the iterator protocol overhead being kind of ridiculous).Maybe you’ll enjoy the deep dive. It usesMessageChannel instead ofwindow.postMessage though, I guess I could update it to make it faster on Safari (I didn’t usescheduler.postTask due to a lack of widespread availability).

    Reply

    • Nolan Lawson's avatar

      That makes sense: a good answer to “Why do browsers work that way?” is often “Because they always have.” 😁

      That said, given that browsers have fiddled with thesetTimeout timing for various conditions (battery, background, etc.), and given that Safari seems to have its ownMessageChannel clamping, maybe timers are more resistant to the adage of “Don’t break the web.”

      Reply

      • jobleonard's avatar

        Posted by jobleonard onSeptember 2, 2025 at 2:42 AM

        Hmm, yeah I guess the fact that it is more likely to be a performance regression than flat-out breaking the page means they have been able to get away with it more easily, which means it might happen again.

        By the way, I took a quick look at your benchmark. It creates a newMessageChannel on every call. Creating a channel once and reusing it also works. Doing so sped it up in both Chrome and Firefox on my machine, to the point where it was faster thanwindow.postMessage, but still slower thanscheduler.postTask in both cases

        Chrome, no reuse:

        4.116 | 0.022 | 0.018 | 0.007

        Reuse:

        4.104 | 0.012 | 0.023 | 0.007

        Firefox, no reuse:

        4.204 | 0.018 | 0.020 | 0.008

        Reuse :

        4.192 | 0.012 | 0.018 | 0.010

        This is making me wonder if the channel creation might be the main bottleneck on Safari as well, instead of the actual event handling. I can’t test it because I’m on a Linux machine.

        Anyway, maybe something to consider if you plan to keep supporting browsers withoutscheduler.postTask withfake-indexeddb for now :)

      • jobleonard's avatar

        Posted by jobleonard onSeptember 2, 2025 at 3:34 AM

        Actually, to add to my previous message, Glenn Maynard noticed in 2011 that using multipleMessageChannelstechnically does not guarantee that messages sent to them are resolved in order that they are posted, because post order is only are guaranteedper channel:

        https://www.w3.org/Bugs/Public/show_bug.cgi?id=15007#c18

        Browsers just happen to do this, although I suppose this has now been “standardised” for the same legacy behavior. His approach of of re-using an single channel with an array as a queue does guarantee it, and per my above changes to your benchmark seems to be faster anyway.

      • jobleonard's avatar

        Posted by jobleonard onSeptember 2, 2025 at 3:48 AM

        Maybe this will work as asetTimeout replacement forfake-indexeddb? It avoids the error-prone aspect ofwindow.postMessage, might be faster due to avoiding the creation of newMessageChannels. If it performs well it also has the nicety of being almost identical tosetTimeout‘s API, so it’s probably a drop-in replacement:

        // Based on Glenn Maynar'sMessageChannel
        const setZeroTimeout = (() => {
        const taskChannel = new MessageChannel(), taskQueue = [];
        taskChannel.port1.onmessage = () => {
        const [task, args] = taskQueue.shift();
        task.apply(task, args);
        };

        // Note that one possible advantage that setZeroTimeout has
        // over requestAnimationFrame is that one can pass it
        // parameters like setTimeout.
        return (task, ...args) => {
        taskQueue.push([task, args]);
        taskChannel.port2.postMessage(null);
        };
        })();

      • Nolan Lawson's avatar

        Nice research! For what it’s worth, I did test reusing oneMessageChannel, but it caused a weird issue in Safari where thefake-indexeddb benchmark never seemed to terminate.That benchmark is linked at the end of the post if you’re curious. (If you’re on Linux rather than Mac, you can test GNOME Web aka Epiphany Browser, which exhibits the same issue as Safari in my testing.)

  2. samuelrouse's avatar

    Posted by samuelrouse onSeptember 5, 2025 at 11:20 AM

    This is great information! Thanks for sharing! In addition to the clamping behavior, theprecision of timers was intentionally reduced after the speculative execution attacks like Spectre in 2018. Timer jitter and precision limits vary across script engines. Not as important if you’re looking for immediate execution, but important if you’re trying to measure time.I wrote about this in 2024, if you’re interested.

    Reply

  3. David C.'s avatar

    Posted by David C. onSeptember 12, 2025 at 6:14 PM

    “This jives with my own intuitions on the topic.”

    I believe the word you are looking for is “jibes”.

    Reply

Leave a commentCancel reply

This site uses Akismet to reduce spam.Learn how your comment data is processed.

Recent Posts

About Me

Photo of Nolan Lawson, headshot

I'm Nolan, a programmer from Seattle working at Socket. All opinions are my own. Photo by Cătălin Mariș.

Archives

Tags

accessibilityalogcatandroidandroid marketappleapp trackerbenchmarkingblobsboostbootstrapbrowsersbug reportscatlogchord readercodecontactscontinuous integrationcopyrightcouch appscouchdbcouchdroiddevelopersdevelopmentemojigrailshtml5indexeddbinformation retrievaljapanese name converterjavascriptjenkinskeepscorelistviewlogcatlogviewerlucenenginxnlpnodenodejsnpmoffline-firstopen sourcepasswordsperformancepinaforepokedroidpouchdbpouchdroidquery expansionrelatedness calculatorrelatedness coefficients3safarisatiresectioned listviewsecuritysemvershadow domsocial mediasocket.iosoftware developmentsolrspassupersaiyanscrollviewsynonymstwitterui designultimate crosswordw3cwebappwebappsweb platformweb socketswebsql

Links

Blog at WordPress.com.


[8]ページ先頭

©2009-2025 Movatter.jp