Event Loop and Timers

By Syncfusion®, Inc. · Last updated

How Node.js runs many jobs on one thread: blocking and non-blocking code, I/O-bound and CPU-bound work, the call stack, libuv and the thread pool, the event loop phases, timers, setImmediate, timers/promises, process.nextTick and the microtask queue, predicting output order, and how to avoid blocking the event loop.

Event Loop and Timers

Event loop = the part of Node.js that picks the next job to run, again and again.

  • Node.js runs your JavaScript on one thread. One thing happens at a time.
  • Slow jobs, like reading a file, are handed to the operating system.
  • When a slow job finishes, its callback waits in a queue.
  • The event loop takes callbacks from the queues and runs them one by one.

Simple lesson: Node.js does one thing at a time, but it never waits around for slow jobs.

A Real-World Example

Think of the school canteen at lunch time.

  • One person stands at the counter.
  • A student orders dosa. The counter person passes the order to the kitchen.
  • She does not wait. She takes the next order.
  • When a plate is ready, the kitchen rings a bell. She hands it over.

The counter person is the event loop. The kitchen is the operating system. The bell is a callback.

You already met the call stack, the microtask queue and the task queue in Asynchronous Fundamentals in the JavaScript course. This lesson shows the extra parts Node.js adds: libuv, the phases, setImmediate and process.nextTick.

What You Will Learn

In this lesson, you will learn:

  1. Single-threaded code, blocking and non-blocking work
  2. I/O-bound and CPU-bound work
  3. The call stack, libuv and the thread pool
  4. The phases of the event loop
  5. setTimeout, setInterval, setImmediate and timers/promises
  6. process.nextTick and the microtask queue
  7. How to predict the order of output
  8. How to keep the event loop free

Concurrency Model

Blocking code = code that makes everything else wait until it finishes.

Non-blocking code = code that starts a job and lets the program go on.

  • readFileSync is blocking. The next line waits for the file.
  • readFile with a callback is non-blocking. The next line runs at once.
  • While a blocking call runs, Node.js cannot answer anyone else.

Click ▶ Run.

  • Line 2 came before line 3, because the sync read finished first.
  • Line 5 came last, because its callback waited for the main code to end.

I/O-Bound vs CPU-Bound Work

  • I/O-bound = waiting for something outside: a file, a database, a website. Node.js is great at this.
  • CPU-bound = hard thinking work: big maths, resizing photos, sorting millions of rows. This blocks the one thread.
  • For CPU-bound work, see Child Processes, Worker Threads and Clustering.

Simple lesson: Node.js shines when it waits a lot, and struggles when it thinks a lot.

Architecture: Call Stack, libuv and the Thread Pool

  • Call stack = the list of functions running right now. V8 manages it.
  • libuv = the C library that runs the event loop and talks to the operating system.
  • Thread pool = 4 helper threads inside libuv for jobs the operating system cannot do without waiting.
  • UV_THREADPOOL_SIZE = an environment variable that changes the pool size (default 4, up to 1024).

Jobs that use the thread pool:

  • Most fs file operations
  • crypto.pbkdf2, crypto.scrypt and other heavy crypto work
  • zlib compression
  • dns.lookup

Network sockets do not use the pool. The operating system tells libuv when data arrives.

Animation: JavaScript on one thread hands file and crypto jobs to four thread pool workers in libuv, and the results come back as callbacks

Main thread your JavaScript

libuv thread pool (4 workers)

worker 1: readFile

worker 2: pbkdf2

worker 3: zlib

worker 4: free A 5th job waits until a worker is free

callback comes back

JavaScript runs on one thread, while libuv's workers do slow file and crypto jobs.

Simple lesson: your code is single-threaded, but libuv has helpers for slow jobs.

Event Loop Phases

Each turn of the event loop visits its queues in a fixed order. One turn is called a tick.

Animation: the event loop moves through the timers, pending callbacks, idle and prepare, poll, check and close callbacks phases in a circle

1. timers 2. pending callbacks 3. idle, prepare 4. poll 5. check 6. close

timers: setTimeout, setInterval poll: file and network callbacks check: setImmediate Between every callback: nextTick, then promises

The loop goes round the phases. Small queues run between every callback.

The three phases you will use most:

  • Timers = runs setTimeout and setInterval callbacks whose time is up.
  • Poll = waits for and runs I/O callbacks, such as a finished file read.
  • Check = runs setImmediate callbacks, right after poll.

The other phases (advanced):

  • Pending callbacks = some system errors from the last turn, such as a refused TCP connection.
  • Idle, prepare = used inside Node.js only.
  • Close callbacks = close events, such as socket.on("close").

When every queue is empty and nothing is waiting, the loop stops and the program ends.

Simple lesson: timers, then I/O, then setImmediate, round and round until nothing is left.

Timers

  • setTimeout(fn, ms) = run fn once, after at least ms milliseconds.
  • setInterval(fn, ms) = run fn again and again, every ms milliseconds.
  • clearTimeout(id) and clearInterval(id) = cancel them.
  • setImmediate(fn) = run fn in the check phase, after the current I/O.
  • Timer accuracy: ms is a minimum, not a promise. A busy program runs the timer late.

timers/promises

node:timers/promises gives promise versions of the timers. They work with await.

ref() and unref()

  • A waiting timer keeps the program alive.
  • timer.unref() = do not keep the program alive just for this timer.
  • timer.ref() = undo that.

Use timers/promises with await instead of nesting setTimeout callbacks. The code then reads from top to bottom.

// main.mjs
const reminder = setInterval(() => console.log("tick"), 1000);
reminder.unref(); // the program may end even though the interval exists
console.log("Main code done. The program ends without waiting.");

Output:

Main code done. The program ends without waiting.

Simple lesson: timers wait at least the time you ask, and await on a timer pauses only your function.

Task Queues: nextTick and Microtasks

Two small queues run between callbacks, before the event loop moves on.

  • process.nextTick queue = runs first, as soon as the current code finishes.
  • Microtask queue = promise callbacks (then, await) and queueMicrotask. Runs right after the nextTick queue.
  • Only then does the loop go to timers, I/O and setImmediate.

Click ▶ Run.

  • Sync code always finishes first.
  • nextTick jumped ahead of the promise, even though it was written later.
  • Promise and queueMicrotask run in the order they were added.
  • setTimeout waited for the next turn of the loop.

Starving the Event Loop

  • If a nextTick callback adds another nextTick, forever, the loop never moves on.
  • Timers and I/O never run. This is called starvation.
  • Prefer queueMicrotask or setImmediate for repeated work.

A while loop that waits for a timer never ends. The timer cannot run while the loop holds the thread.

Simple lesson: sync, then nextTick, then promises, then the event loop phases.

Execution Order

Ordering Inside I/O Callbacks vs the Main Module

  • In the main module, setTimeout(fn, 0) and setImmediate(fn) can run in either order. It depends on how busy the computer is.
  • Inside an I/O callback, setImmediate always runs first. The check phase comes right after poll.
// main.mjs
import { readFile } from "node:fs";

readFile(import.meta.filename, () => {
  // We are in the poll phase now.
  setTimeout(() => console.log("timeout"), 0);
  setImmediate(() => console.log("immediate"));
});

Output:

immediate
timeout

Predicting Output Order

Use these steps:

  1. Run all sync code from top to bottom.
  2. Run all nextTick callbacks.
  3. Run all promise callbacks.
  4. Go to the next phase of the loop. Repeat steps 2 and 3 after every callback.

Simple lesson: inside I/O callbacks, setImmediate beats setTimeout.

Blocking the Event Loop

Common blocking operations:

  • readFileSync and other ...Sync calls on big files
  • Long for loops over huge arrays
  • JSON.parse of a very large text
  • Heavy maths, such as checking primes up to millions
  • A badly written regular expression on long text

Splitting Work with setImmediate

  • Cut a long job into small pieces.
  • Do one piece, then schedule the next with setImmediate.
  • Between pieces, the event loop can run timers and I/O.

Simple lesson: never hold the one thread for long. Cut big work into small pieces.

Common mistakes

MistakeWrong ideaCorrect ideaWhat goes wrong
Using readFileSync in a busy serverSync is simpler and just as fastUse fs/promises or callbacksEvery user waits while one file is read
Forgetting clearIntervalThe interval stops by itselfKeep the id and clear itThe program never ends
Trusting setTimeout(fn, 100) to be exactTimers are preciseThe delay is a minimumCode runs late when the loop is busy
Calling nextTick inside nextTick foreverIt is just a fast setTimeoutIt runs before the loop moves onTimers and I/O starve
Long loops in a request handlerOne loop is harmlessSplit the work or use a workerAll other users freeze
Expecting a fixed order of setTimeout 0 and setImmediate in main codeOne always winsIt can change between runsFlaky output and tests

TL;DR

  1. Event loop = picks the next callback to run, one at a time.
  2. Blocking = everything waits. Non-blocking = start the job and move on.
  3. libuv = runs the loop and the thread pool (4 workers by default).
  4. Phases = timers, pending, idle/prepare, poll, check, close.
  5. setTimeout = timers phase. setImmediate = check phase.
  6. Order = sync code, then nextTick, then promises, then the next phase.
  7. Starvation = endless nextTick work stops the loop.
  8. Split long work with setImmediate, or move it to a worker thread.

Check Yourself

Exercises

What to Remember

  • Node.js runs JavaScript on one thread and never waits for slow jobs.
  • libuv runs the event loop and a small thread pool.
  • The loop visits timers, poll (I/O) and check (setImmediate), among others.
  • Sync code runs first, then nextTick, then promises, then the next phase.
  • Timers wait at least the time you ask, never less.
  • Split long work, or the whole program freezes.

Next: Events and Utilities. You will make your own events and use the util toolbox.