Skip to main content

JavaScript and TypeScript

The JavaScript Event Loop Explained Through Real Production Bugs

Understand the JavaScript event loop through production bugs involving blocked requests, microtask starvation, concurrency, timers, and backpressure.

Written by Aminul Islam Alvi16 min read

The JavaScript event loop is easy to explain badly.

The usual explanation draws a call stack, a callback queue, and a circle of arrows. It is useful for learning why a timer callback does not run in the middle of a function. It is less useful when an API's p99 latency suddenly reaches five seconds, a queue worker processes the same job twice, or a file export consumes enough memory to restart the container.

I understand the event loop through the bugs it permits. The important fact is not the name of every phase. It is that JavaScript callbacks take turns on an event loop, each callback runs synchronously until it returns, and the next useful piece of work cannot run while one turn keeps the JavaScript thread busy.

That model explains several production incidents that otherwise look unrelated. Let us work through five of them and, more importantly, the evidence that separates one from another.

The event loop model that helps in production

Node.js uses one JavaScript thread by default, but the process is not doing only one thing at a time. The operating system can handle network I/O in the background. Node also uses libuv facilities, including a worker pool for some filesystem, DNS, compression, and cryptographic work. When an operation can make progress elsewhere, the JavaScript thread does not need to sit and wait for it.

When that work is ready, its callback still needs a turn to run. The official Node.js event loop guide describes phases for timers, pending callbacks, polling for I/O, setImmediate, and close callbacks. Promises and process.nextTick add higher-priority work between those turns.

I keep a simpler operational model in my head:

  1. Run the current JavaScript until its stack is empty.
  2. Drain the scheduled microtask work that has priority now.
  3. Let the event loop move through ready timers, I/O, immediate callbacks, and other phases.
  4. Repeat while handles or scheduled work keep the process alive.

Two details prevent many wrong diagnoses.

First, async does not mean "on another thread." An async function runs synchronously until it reaches an operation that actually yields. If it performs 500 milliseconds of calculation before the first await, it blocks the JavaScript thread for 500 milliseconds.

Second, non-blocking does not mean unlimited. Starting 20,000 HTTP requests may leave the event loop free to turn, but it can exhaust sockets, memory, database connections, rate limits, or the remote service. That is a concurrency failure, not necessarily an event loop blockage.

The event loop phases matter when ordering matters. For most production debugging, I start with three questions instead:

  1. Is one callback keeping the JavaScript thread busy?
  2. Is high-priority scheduled work preventing I/O from getting a turn?
  3. Is the loop free, but the application has admitted more work than a dependency can handle?

The following bugs make those differences concrete.

Bug 1: One CPU-heavy request stalls everyone

Imagine a reporting endpoint that reads 100,000 records and builds a large JSON response. It works in staging. In production, a few large customers run the report at the same time. Health checks begin timing out, ordinary API requests slow down, and the database dashboard looks healthy.

The code may contain await and still block:

export async function buildReport(accountId) {
  const rows = await reportRepository.findAll(accountId)

  const ranked = rows
    .map(calculateScore)
    .sort((left, right) => right.score - left.score)

  return JSON.stringify(ranked)
}

The database call yields while it waits. The mapping, sorting, and serialization do not. They are JavaScript work in one event loop turn. If they take 700 milliseconds, unrelated socket callbacks and timers in that process wait too.

This is why a single slow request can create system-wide latency. The process may accept many connections, but their JavaScript handlers still share the same turn-taking mechanism.

Large synchronous operations appear in less obvious places:

  1. JSON.parse and JSON.stringify on unusually large payloads.
  2. Complex regular expressions with catastrophic backtracking.
  3. Synchronous filesystem or child-process APIs in a request path.
  4. Compression, encryption, image work, or PDF generation performed synchronously.
  5. Large array transforms, sorts, deep clones, and schema validation walks.
  6. Logging an enormous object when the logger serializes it synchronously.

The Node.js guidance on blocking the event loop frames this as both a performance and security concern: work associated with one client should remain small enough that other clients receive fair turns.

I would verify this bug with event loop delay, a CPU profile, and request-level timing around the suspected section. High CPU plus high event loop delay is a strong direction, but not a full diagnosis. A profile should show where the time goes.

The fix depends on the work:

  1. Reduce it with pagination, a better query, a more compact representation, or an earlier limit.
  2. Stream results instead of building the full representation in memory.
  3. Break interruptible work into bounded batches and yield between them.
  4. Move genuinely CPU-intensive JavaScript to a worker-thread pool or another process.
  5. Turn a long request into a background job when the product permits asynchronous completion.

Worker threads are not a generic "make it faster" switch. Node's worker_threads documentation recommends them for CPU-intensive JavaScript and notes that they do not help much with I/O-intensive work. Creating a new worker for every request also adds overhead; repeated tasks normally need a pool.

Bug 2: Microtasks starve I/O and timers

Now imagine CPU is not especially high, but WebSocket messages stop flowing and timers run late whenever a certain retry path activates.

The code looks asynchronous:

function retryImmediately(operation) {
  return operation().catch(() =>
    Promise.resolve().then(() => retryImmediately(operation)),
  )
}

If operation keeps rejecting immediately, each retry schedules another promise reaction. Promise callbacks run as microtasks. The runtime keeps draining that work before returning to ordinary event loop phases, so I/O and timers may not receive a fair turn.

process.nextTick can create an even clearer version:

function drainQueue(queue) {
  const item = queue.shift()
  if (!item) return

  processItem(item)
  process.nextTick(() => drainQueue(queue))
}

For a large or continuously refilled queue, the recursive next-tick chain can starve the poll phase. Node's event loop documentation explicitly warns that recursive process.nextTick() calls can prevent the loop from reaching I/O.

This bug is confusing because the call stack keeps unwinding. Nothing looks like a long while loop. The application is nevertheless choosing more high-priority JavaScript before giving I/O a chance.

The fix is to decide what "later" should mean. If the work should continue after I/O has had a turn, yield to the event loop instead of scheduling another microtask:

import { setImmediate as yieldToEventLoop } from 'node:timers/promises'

export async function processInBatches(items, batchSize, processItem) {
  for (let start = 0; start < items.length; start += batchSize) {
    const batch = items.slice(start, start + batchSize)

    for (const item of batch) {
      await processItem(item)
    }

    await yieldToEventLoop()
  }
}

Yielding after every item can make throughput unnecessarily poor. Never yielding can make latency unbounded. Batch size is a product and performance decision: measure how it affects both job completion time and the p99 latency of other work.

Retries also need delay, a limit, and usually backoff with jitter. An immediate infinite retry is dangerous even when it is perfectly fair to the event loop. It can keep a broken dependency under constant load.

Bug 3: Promise.all overwhelms a dependency

This incident begins with a reasonable optimization. A sequential loop is slow, so someone replaces it with Promise.all:

const results = await Promise.all(
  customerIds.map((customerId) => refreshCustomer(customerId)),
)

It flies through a test with 20 customers. A production account has 30,000. The service opens thousands of outbound requests, the database pool queues work, the provider starts returning 429, and memory rises while every promise and response waits to settle.

The event loop is not necessarily blocked. It may be turning constantly and handling completions correctly. The bug is that the application admitted work without a bound.

This distinction matters because worker threads will not fix it. More application instances can make it worse by multiplying concurrency. The control you need is backpressure: the producer must slow down when the consumer or dependency reaches a safe amount of in-flight work.

A small bounded mapper is often enough:

export async function mapWithConcurrency(items, concurrency, worker) {
  if (!Number.isInteger(concurrency) || concurrency < 1) {
    throw new RangeError('concurrency must be a positive integer')
  }

  const results = new Array(items.length)
  let nextIndex = 0

  async function run() {
    while (nextIndex < items.length) {
      const index = nextIndex
      nextIndex += 1
      results[index] = await worker(items[index], index)
    }
  }

  const workerCount = Math.min(concurrency, items.length)
  await Promise.all(Array.from({ length: workerCount }, () => run()))

  return results
}

The correct concurrency value is not "as high as possible." It depends on the database pool, provider quota, latency, memory per task, fairness between tenants, and how quickly failure should propagate. I usually begin below the known downstream limit, measure, and leave headroom for ordinary traffic.

Also define failure semantics. The example stops each runner when its current task rejects, while already-started tasks continue. A production implementation may need cancellation, per-item results, retries for selected errors, or a durable queue. The important improvement is that admission is explicit.

Bug 4: A late timer breaks a correctness guarantee

Timers promise a minimum delay, not an appointment.

When you call setTimeout(callback, 1_000), the callback becomes eligible after roughly that threshold. It still needs the event loop to reach it. Node's timer documentation in the event loop guide is explicit that operating-system scheduling and other callbacks can delay execution.

That is harmless for many UI updates. It becomes a correctness bug when a timer renews ownership:

const lease = await acquireLease('daily-invoice-job', { ttlMs: 30_000 })

const renewal = setInterval(async () => {
  await lease.extend({ ttlMs: 30_000 })
}, 10_000)

await generateInvoices()
clearInterval(renewal)
await lease.release()

Suppose generateInvoices() performs enough synchronous work to block the loop for 35 seconds. The renewal callback cannot run. The lease expires, another worker acquires it, and two workers can now generate the same invoices.

The primary failure is not that the timer is inaccurate. The design treated process-local scheduling as a hard distributed-systems guarantee.

The same pattern affects:

  1. Queue visibility-timeout renewal.
  2. Distributed lock heartbeats.
  3. WebSocket ping and idle detection.
  4. Request deadlines implemented only with local timers.
  5. Cache refresh scheduled just before expiry.
  6. Health checks that report late because the service is already unhealthy.

The fix has two layers. First, remove or isolate the work that creates unacceptable event loop delay. Second, design the operation so a missed heartbeat does not corrupt data. Use fencing tokens or conditional writes where appropriate, make side effects idempotent, and check ownership again before committing a result. A lease reduces overlap; it should not be your only duplicate-work defense.

Monitor the renewal result too. An async interval can overlap with itself if one renewal takes longer than the interval, and a rejection can be lost when nothing observes the returned promise. A self-scheduling loop that awaits each renewal is often easier to control than setInterval.

Bug 5: Ignored backpressure becomes a memory incident

Consider a CSV export that reads rows quickly and writes them to a slower network response:

for await (const row of databaseRows) {
  response.write(toCsv(row))
}

response.end()

Writable.write() returns a boolean. When it returns false, the internal buffer has reached its threshold and the producer should wait for the drain event. Ignoring that signal does not make the network faster. It keeps adding chunks to memory.

At first, the incident looks like a memory leak. Resident memory rises during large exports, garbage collection becomes more frequent, and event loop delay rises with it. The process may eventually be killed even though all buffered objects would become collectible if the destination caught up.

Node's stream documentation explains that streams buffer data and use highWaterMark to signal backpressure. It also warns that continuing to write while a stream is not draining can produce high memory use, poor garbage-collection behavior, and eventually an unconditional abort.

Respect the return value when writing manually:

import { once } from 'node:events'

for await (const row of databaseRows) {
  if (!response.write(toCsv(row))) {
    await once(response, 'drain')
  }
}

response.end()

In a real HTTP handler, also handle client disconnects and write errors so the database iterator does not continue producing rows after the destination disappears. When possible, use stream.pipeline(), which coordinates backpressure and forwards stream errors more reliably than hand-written event wiring.

This bug connects memory and the event loop. Heap growth is not itself event loop blocking, but allocating and collecting a large backlog consumes CPU and lengthens pauses. One missing flow-control decision can therefore appear as memory pressure, latency, and instability at the same time.

Measure the event loop before fixing it

"The event loop is slow" is a hypothesis. Instrumentation should tell you whether it is the useful one.

Node provides monitorEventLoopDelay() in node:perf_hooks. It samples how late the loop is in returning to a timer and exposes a histogram in nanoseconds. This minimal probe reports a ten-second p99:

import { monitorEventLoopDelay } from 'node:perf_hooks'

const delay = monitorEventLoopDelay({ resolution: 20 })
delay.enable()

setInterval(() => {
  const p99Milliseconds = delay.percentile(99) / 1e6

  console.log({
    metric: 'nodejs_event_loop_delay_p99_ms',
    value: Number(p99Milliseconds.toFixed(2)),
  })

  delay.reset()
}, 10_000).unref()

The performance hooks documentation also provides event loop utilization. Delay and utilization answer different questions. Delay shows how late scheduled loop work became. Utilization estimates how much time the loop spent active instead of waiting in its event provider. Neither identifies the responsible function by itself.

Correlate them with:

  1. Process CPU, heap, resident memory, and garbage-collection activity.
  2. Request latency and throughput, split by route or operation.
  3. Database pool wait time and downstream request latency.
  4. Queue depth, job duration, retry count, and concurrency.
  5. CPU profiles or flame graphs captured during the symptom.
  6. Payload and batch sizes, with customer identifiers handled safely.

Interpret combinations rather than one threshold:

EvidenceLikely direction
High event loop delay and high process CPUCPU-heavy JavaScript, synchronous APIs, serialization, or GC pressure
Low event loop delay but high request latencyDatabase, network, pool queueing, lock contention, or another dependency
Rising memory followed by delay and CPU spikesUnbounded buffering, allocation pressure, cache growth, or a leak causing GC work
High event loop utilization with modest CPUA blocking synchronous operation may be waiting without using much CPU
Provider errors and pool waits with normal loop delayUnbounded concurrency or missing admission control

These are directions for investigation, not automatic conclusions. Capture a profile, reproduce with representative sizes, and compare before and after the change.

Alerting needs a window and a user impact. One brief delay during startup may not matter. Sustained p99 delay alongside API latency or missed heartbeats does. Establish the normal range for each workload instead of copying a universal threshold.

Choose the fix that matches the work

Most event loop fixes fit one of four moves.

Reduce the work

Do less before trying to schedule it more cleverly. Add limits, improve the query, avoid repeated parsing, precompute stable results, or stop serializing fields nobody needs. This is often the cheapest and safest fix.

Yield between bounded pieces

Partition work when it can be paused and when total completion time remains acceptable. setImmediate or its promise-based form lets I/O receive turns between batches. Measure the overhead and choose a batch size from actual latency goals.

Offload CPU work

Use a worker-thread pool, another process, or a separate service for substantial CPU work that cannot be reduced or partitioned well. Include queue limits. Moving an unbounded workload to workers only relocates the overload.

Add backpressure

Limit in-flight promises, respect stream signals, cap queue consumption, and align concurrency with downstream capacity. Backpressure protects memory and dependencies even when the event loop itself is responsive.

Scaling out belongs after these choices, not before them. More Node.js processes can increase capacity and improve isolation, but they do not make one blocked process responsive. They can also multiply database connections and outbound concurrency. Fix the per-process failure mode and then scale the corrected design.

An event loop review checklist

When I review a Node.js workflow, I ask:

  1. What JavaScript runs synchronously before the first real yield?
  2. Can input size make that work unexpectedly large?
  3. Are synchronous filesystem, crypto, compression, or child-process APIs on a request path?
  4. Can promises or process.nextTick continuously schedule more high-priority work?
  5. Is concurrency explicitly bounded at every fan-out?
  6. What happens when the database pool or remote service is slower than the producer?
  7. Does stream code respect backpressure and cancellation?
  8. Does correctness depend on a timer firing promptly?
  9. Can retries amplify the original overload?
  10. Do event loop metrics connect to routes, jobs, payload sizes, CPU, and memory?
  11. Is CPU-heavy work reduced, partitioned, or offloaded through a bounded pool?
  12. Has the fix been tested with production-shaped data and concurrent ordinary traffic?

The event loop stops feeling mysterious once you treat it as a shared scheduling resource. Every callback gets a turn. Production problems begin when one turn is too long, privileged work never stops scheduling itself, or producers create more future turns than the rest of the system can absorb.

That is the model worth carrying into code review and incident response.

The runnable examples in this article were verified with Node.js 22.17.1. If you are hiring for this kind of production work, see my guide to interviewing a Senior Node.js and TypeScript Engineer. You can also review my TypeScript and Node.js engineering experience.

Frequently asked questions

Common questions about the JavaScript event loop

Is Node.js single-threaded?

JavaScript runs on one thread by default, but a Node.js process uses the operating system and libuv facilities to make other work progress concurrently. Worker threads can run JavaScript in parallel. The important constraint is that callbacks returning to one event loop still share its JavaScript execution time.

Does await block the event loop?

Waiting on a genuinely asynchronous operation does not block the event loop. JavaScript executed before the await, after the promise settles, or inside a promise callback still runs synchronously on the JavaScript thread. Awaiting CPU-heavy work does not automatically move that work elsewhere.

What is event loop lag?

Event loop lag or delay is how late the runtime becomes in returning to scheduled loop work because other work held it up. It is useful as a latency signal, but it does not identify the cause. Correlate it with CPU, memory, garbage collection, profiles, and dependency timings.

What is the difference between process.nextTick, a promise, and setImmediate?

Next-tick callbacks and promise reactions run as high-priority work before the loop continues to ordinary phases, with Node giving its next-tick queue special treatment. setImmediate runs in the check phase after polling for I/O. Use these APIs for their scheduling semantics, not because one sounds generically faster.

When should I use worker threads?

Use a bounded worker-thread pool for substantial CPU-intensive JavaScript when reducing or partitioning the work is not enough. Worker threads rarely improve ordinary asynchronous I/O. Include task limits, timeouts, error handling, and observability around the pool.

Can Promise.all block the event loop?

Promise.all does not by itself perform synchronous blocking. It can start an unbounded amount of asynchronous work, consume memory, exhaust connection pools, and overload dependencies. The callbacks that process all those results also need event loop time. Limit concurrency based on downstream capacity.

JavaScriptNode.jsEvent loopBackend performance