The Node.js Event Loop
What is the Event Loop?
The event loop is what allows Node.js to perform non-blocking I/Ooperations — despite the fact that a single JavaScript thread is used by default — byoffloading operations to the system kernel whenever possible.
Since most modern kernels are multi-threaded, they can handle multipleoperations executing in the background. When one of these operationscompletes, the kernel tells Node.js so that the appropriate callbackmay be added to thepoll queue to eventually be executed. We'll explainthis in further detail later in this topic.
Event Loop Explained
When Node.js starts, it initializes the event loop, processes theprovided input script (or drops into theREPL, which is not covered inthis document) which may make async API calls, schedule timers, or callprocess.nextTick(), then begins processing the event loop.
The following diagram shows a simplified overview of the event loop'sorder of operations.
┌───────────────────────────┐┌─>│timers││└─────────────┬─────────────┘│┌─────────────┴─────────────┐││pendingcallbacks││└─────────────┬─────────────┘│┌─────────────┴─────────────┐││idle,prepare││└─────────────┬─────────────┘┌───────────────┐│┌─────────────┴─────────────┐│incoming:│││poll│<─────┤connections,││└─────────────┬─────────────┘│data,etc.││┌─────────────┴─────────────┐└───────────────┘││check││└─────────────┬─────────────┘│┌─────────────┴─────────────┐└──┤closecallbacks│└───────────────────────────┘Each box will be referred to as a "phase" of the event loop.
Each phase has a FIFO queue of callbacks to execute. While each phase isspecial in its own way, generally, when the event loop enters a givenphase, it will perform any operations specific to that phase, thenexecute callbacks in that phase's queue until the queue has beenexhausted or the maximum number of callbacks has executed. When thequeue has been exhausted or the callback limit is reached, the eventloop will move to the next phase, and so on.
Since any of these operations may schedulemore operations and newevents processed in thepoll phase are queued by the kernel, pollevents can be queued while polling events are being processed. As aresult, long running callbacks can allow the poll phase to run muchlonger than a timer's threshold. See thetimers andpoll sections for more details.
There is a slight discrepancy between the Windows and theUnix/Linux implementation, but that's not important for thisdemonstration. The most important parts are here. There are actuallyseven or eight steps, but the ones we care about — ones that Node.jsactually uses - are those above.
Phases Overview
- timers: this phase executes callbacks scheduled by
setTimeout()andsetInterval(). - pending callbacks: executes I/O callbacks deferred to the next loopiteration.
- idle, prepare: only used internally.
- poll: retrieve new I/O events; execute I/O related callbacks (almostall with the exception of close callbacks, the ones scheduled by timers,and
setImmediate()); node will block here when appropriate. - check:
setImmediate()callbacks are invoked here. - close callbacks: some close callbacks, e.g.
socket.on('close', ...).
Between each run of the event loop, Node.js checks if it is waiting forany asynchronous I/O or timers and shuts down cleanly if there are notany.
Starting with libuv 1.45.0 (Node.js 20), the event loop behaviorchanged to run timers only after thepoll phase, instead of both before and afteras in earlier versions. This change can affect the timing ofsetImmediate() callbacksand how they interact with timers in certain scenarios.
Phases in Detail
timers
A timer specifies thethresholdafter which a provided callbackmay be executed rather than theexact time a personwants it tobe executed. Timers callbacks will run as early as they can bescheduled after the specified amount of time has passed; however,Operating System scheduling or the running of other callbacks may delaythem.
Technically, thepoll phase controls when timers are executed.
For example, say you schedule a timeout to execute after a 100 msthreshold, then your script starts asynchronously reading a file whichtakes 95 ms:
const =('node:fs');function() { // Assume this takes 95ms to complete.('/path/to/file',);}const =.();(() => { const =.()-;.(`${}ms have passed since I was scheduled`);}, 100);// do someAsyncOperation which takes 95 ms to complete(() => { const =.(); // do something that will take 10ms... while (.()- < 10){ // do nothing }});When the event loop enters thepoll phase, it has an empty queue(fs.readFile() has not completed), so it will wait for the number of msremaining until the soonest timer's threshold is reached. While it iswaiting 95 ms pass,fs.readFile() finishes reading the file and itscallback which takes 10 ms to complete is added to thepoll queue andexecuted. When the callback finishes, there are no more callbacks in thequeue, so the event loop will see that the threshold of the soonesttimer has been reached then wrap back to thetimers phase to executethe timer's callback. In this example, you will see that the total delaybetween the timer being scheduled and its callback being executed willbe 105ms.
To prevent thepoll phase from starving the event loop,libuv(the C library that implements the Node.jsevent loop and all of the asynchronous behaviors of the platform)also has a hard maximum (system dependent) before it stops polling formore events.
pending callbacks
This phase executes callbacks for some system operations such as typesof TCP errors. For example if a TCP socket receivesECONNREFUSED whenattempting to connect, some *nix systems want to wait to report theerror. This will be queued to execute in thepending callbacks phase.
poll
Thepoll phase has two main functions:
- Calculating how long it should block and poll for I/O, then
- Processing events in thepoll queue.
When the event loop enters thepoll phaseand there are no timersscheduled, one of two things will happen:
If thepoll queueis not empty, the event loop will iteratethrough its queue of callbacks executing them synchronously untileither the queue has been exhausted, or the system-dependent hard limitis reached.
If thepoll queueis empty, one of two more things willhappen:
If scripts have been scheduled by
setImmediate(), the event loopwill end thepoll phase and continue to thecheck phase toexecute those scheduled scripts.If scriptshave not been scheduled by
setImmediate(), theevent loop will wait for callbacks to be added to the queue, thenexecute them immediately.
Once thepoll queue is empty the event loop will check for timerswhose time thresholds have been reached. If one or more timers areready, the event loop will wrap back to thetimers phase to executethose timers' callbacks.
check
This phase allows the event loop to execute callbacks immediately after thepoll phase has completed. If thepoll phase becomes idle andscripts have been queued withsetImmediate(), the event loop maycontinue to thecheck phase rather than waiting.
setImmediate() is actually a special timer that runs in a separatephase of the event loop. It uses a libuv API that schedules callbacks toexecute after thepoll phase has completed.
Generally, as the code is executed, the event loop will eventually hitthepoll phase where it will wait for an incoming connection, request,etc. However, if a callback has been scheduled withsetImmediate()and thepoll phase becomes idle, it will end and continue to thecheck phase rather than waiting forpoll events.
close callbacks
If a socket or handle is closed abruptly (e.g.socket.destroy()), the'close' event will be emitted in this phase. Otherwise it will beemitted viaprocess.nextTick().
setImmediate() vssetTimeout()
setImmediate() andsetTimeout() are similar, but behave in differentways depending on when they are called.
setImmediate()is designed to execute a script once thecurrentpoll phase completes.setTimeout()schedules a script to be run after a minimum thresholdin ms has elapsed.
The order in which the timers are executed will vary depending on thecontext in which they are called. If both are called from within themain module, then timing will be bound by the performance of the process(which can be impacted by other applications running on the machine).
For example, if we run the following script which is not within an I/Ocycle (i.e. the main module), the order in which the two timers areexecuted is non-deterministic, as it is bound by the performance of theprocess:
// timeout_vs_immediate.js(() => {.('timeout');}, 0);(() => {.('immediate');});However, if you move the two calls within an I/O cycle, the immediatecallback is always executed first:
// timeout_vs_immediate.jsconst =('node:fs');.(, () => {(() => {.('timeout'); }, 0);(() => {.('immediate'); });});The main advantage to usingsetImmediate() oversetTimeout() issetImmediate() will always be executed before any timers if scheduledwithin an I/O cycle, independently of how many timers are present.
process.nextTick()
Understandingprocess.nextTick()
You may have noticed thatprocess.nextTick() was not displayed in thediagram, even though it's a part of the asynchronous API. This is becauseprocess.nextTick() is not technically part of the event loop. Instead,thenextTickQueue will be processed after the current operation iscompleted, regardless of the current phase of the event loop. Here,anoperation is defined as a transition from theunderlying C/C++ handler, and handling the JavaScript that needs to beexecuted.
Looking back at our diagram, any time you callprocess.nextTick() in agiven phase, all callbacks passed toprocess.nextTick() will beresolved before the event loop continues. This can create some badsituations becauseit allows you to "starve" your I/O by makingrecursiveprocess.nextTick() calls, which prevents the event loopfrom reaching thepoll phase.
Why would that be allowed?
Why would something like this be included in Node.js? Part of it is adesign philosophy where an API should always be asynchronous even whereit doesn't have to be. Take this code snippet for example:
function(,) { if (typeof !== 'string'){ return.(, new('argument should be string') ); }}The snippet does an argument check and if it's not correct, it will passthe error to the callback. The API updated fairly recently to allowpassing arguments toprocess.nextTick() allowing it to take anyarguments passed after the callback to be propagated as the arguments tothe callback so you don't have to nest functions.
What we're doing is passing an error back to the user but onlyafterwe have allowed the rest of the user's code to execute. By usingprocess.nextTick() we guarantee thatapiCall() always runs itscallbackafter the rest of the user's code andbefore the event loopis allowed to proceed. To achieve this, the JS call stack is allowed tounwind then immediately execute the provided callback which allows aperson to make recursive calls toprocess.nextTick() without reaching aRangeError: Maximum call stack size exceeded from v8.
This philosophy can lead to some potentially problematic situations.Take this snippet for example:
let = null;// this has an asynchronous signature, but calls callback synchronouslyfunction() {();}// the callback is called before `someAsyncApiCall` completes.(() => { // since someAsyncApiCall hasn't completed, bar hasn't been assigned any value.('bar',); // null}); = 1;The user definessomeAsyncApiCall() to have an asynchronous signature,but it actually operates synchronously. When it is called, the callbackprovided tosomeAsyncApiCall() is called in the same phase of theevent loop becausesomeAsyncApiCall() doesn't actually do anythingasynchronously. As a result, the callback tries to referencebar eventhough it may not have that variable in scope yet, because the script has notbeen able to run to completion.
By placing the callback in aprocess.nextTick(), the script still has theability to run to completion, allowing all the variables, functions,etc., to be initialized prior to the callback being called. It also hasthe advantage of not allowing the event loop to continue. It may beuseful for the user to be alerted to an error before the event loop isallowed to continue. Here is the previous example usingprocess.nextTick():
let = null;function() {.();}(() => {.('bar',); // 1}); = 1;Here's another real world example:
const =net.createServer(() => {}).listen(8080);.on('listening', () => {});When only a port is passed, the port is bound immediately. So, the'listening' callback could be called immediately. The problem is that the.on('listening') callback will not have been set by that time.
To get around this, the'listening' event is queued in anextTick()to allow the script to run to completion. This allows the user to setany event handlers they want.
process.nextTick() vssetImmediate()
We have two calls that are similar as far as users are concerned, buttheir names are confusing.
process.nextTick()fires immediately on the same phasesetImmediate()fires on the following iteration or 'tick' of theevent loop
In essence, the names should be swapped.process.nextTick() fires moreimmediately thansetImmediate(), but this is an artifact of the pastwhich is unlikely to change. Making this switch would break a largepercentage of the packages on npm. Every day more new modules are beingadded, which means every day we wait, more potential breakages occur.While they are confusing, the names themselves won't change.
We recommend developers use
setImmediate()in all cases because it'seasier to reason about.
Why useprocess.nextTick()?
There are two main reasons:
Allow users to handle errors, cleanup any then unneeded resources, orperhaps try the request again before the event loop continues.
At times it's necessary to allow a callback to run after the callstack has unwound but before the event loop continues.
One example is to match the user's expectations. Simple example:
const =net.createServer();.on('connection', => {});.listen(8080);.on('listening', () => {});Say thatlisten() is run at the beginning of the event loop, but thelistening callback is placed in asetImmediate(). Unless ahostname is passed, binding to the port will happen immediately. Forthe event loop to proceed, it must hit thepoll phase, which meansthere is a non-zero chance that a connection could have been receivedallowing the connection event to be fired before the listening event.
Another example is extending anEventEmitter and emitting anevent from within the constructor:
const =('node:events');class extends { constructor() { super(); this.('event'); }}const = new();.('event', () => {.('an event occurred!');});You can't emit an event from the constructor immediatelybecause the script will not have processed to the point where the userassigns a callback to that event. So, within the constructor itself,you can useprocess.nextTick() to set a callback to emit the eventafter the constructor has finished, which provides the expected results:
const =('node:events');class extends { constructor() { super(); // use nextTick to emit the event once a handler is assigned.(() => { this.('event'); }); }}const = new();.('event', () => {.('an event occurred!');});