Node.js event-loop ordering is irrelevant

Knowing the ordering of setImmediate vs. setTimeout makes you a worse developer

uNetworking AB
3 min readFeb 18, 2024

Every now and then you see these blog posts from companies proudly explaining the order of execution in the Node.js event-loop. They go over, like a cheat sheet, in what order setImmediate, setTimeout, Promise.resolve and nextTick will fire. Then you remember the order and go to your job interview and regurgitate the list without even considering that such an ordering really is arbitrary and non-deterministic. It even changed throughout Node.js history and can be different between platforms.

Writing code that relies on the exact ordering of these different classes of async operations is at best coincidentally non-broken.

Per definition, any async operation with a completion handler (be it callback, or promise or await) cannot be assumed to finish in any particular order, other than the fact its completion handler will fire whenever so is the case. Assuming any kind of inter-async operational order is broken by definition.

If you write code that is correct, it will work correctly on any platform, in any browser and on any runtime, now and in the future. If you write code that just coincidentally works here, it most likely won’t work over there.

The order of completion between these async operations is technical details and arbitrary decisions that can, have and will change over time. Node.js nextTick behavior has changed radically since early days of Node.js and the exact order of Promise microtask execution is entirely arbitrary and can be changed by native addons.

V8 has no knowledge of any event-loop and can run microtasks in different modes depending on configuration; kAuto, kExplicit or kScoped. For instance, using the mode kAuto would make Promises run before nextTick. Again, this is entirely arbitrarily chosen and can change.

Further on, not only does order depend on the V8 configs, or what event-loop library or even what version of said event-loop library is used. It also depends on the platform the event-loop runs on. For instance, on epoll platforms that use timerfd to implement timers (which is the efficient way), there is no guaranteed or documented in-kernel ordering between sockets, files or timers. So making any such high level assumption in script, is really just coincidentally non-broken and depends on the event-loop not making use of timerfds (or whatever new kernel features will be used in the future).

How sane and reliable do you really think it is for high level scripts to depend on whether or not the event-loop use timerfd syscalls or not? Do you think that kind of dependency is according to any accepted software engineering practices? Do you as high level scripter want to incur a blockade on the low levels using or not using more efficient syscalls? Because you see, the more high level code depend on particular low level behavior, the less room for low level optimization there will be. You are part of the problem, not the solution.

If you look at Bun, Node.js and Deno, they all run these async operations in different order even though they all implement the same interfaces. This of course comes from their technical variance. The interface you program against is implemented in vastly different ways by vastly different code bases. This in order to compete and move the runtime industry forward for the better.

Heck, even if you run Node.js on epoll, eventually you will swap over to libuv backed by io_uring and here you most likely will see dramatical differences in the ordering unless libuv implements lower performing limitations to feed you the same exact behavior because people wrote a bunch of broken software that won’t work on faster runtimes. Depending on low level peculiar and arbitrary details locks up the ability for runtimes to seamlessly improve and compete.

Point being; don’t mindlessly learn to regurgitate details that just so happens to be arbitrarily chosen, if not straight out non-deterministic altogether. It can be good to know that uv_check_t runs after uv_prepare_t but that’s about all the guarantees you get. Better is to understand how an event-loop works, technically, and understand that there are no such guarantees given by the system.

If you write code that blindly relies on a particular order of execution, then your tasks aren’t async. They are sync, by definition, and shouldn’t even be part of the event-loop.

Of course, I’m not talking about Promise.all or similar synchronization primitives that explicitly tell the runtime what your async dependencies are. I’m talking about implicit assumptions of low level behavior where code simply breaks down if those assumptions break.

--

--