Home / Articles

Understanding JavaScript Closures and the Event Loop

JavaScript closures keep outer variables alive; the event loop orders the call stack, microtasks and macrotasks. Both explained with runnable code examples.

Publisher: REACTAPP.TOP16 min read3605 words

This article is published in English.

Sources: JavaScript Closures Explained in 2 Minutes, The JavaScript Event Loop — Finally Explained With Zero Confusion

JavaScript often gets described as a language full of quirks, but two of its most misunderstood behaviors—closures and the event loop—actually come from the same root idea: functions and scheduled work don't disappear just because the code around them has moved on. Understanding how a function can "remember" variables after its parent has returned, and how the runtime decides when to run code that isn't immediately ready, both require you to stop thinking about JavaScript as a simple line-by-line script and start thinking about it as a system with memory and timing rules. This article walks through both ideas, starting with closures.

Understanding Closures

What a Closure Actually Is

A closure happens when a function keeps hold of variables from the scope it was created in, and can still reach them even after that outer function has already finished running.

A useful way to picture this is a function carrying a small backpack of variables wherever it goes.

function createCounter() {
  let count = 0;
return function () {
    count++;
    return count;
  };
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

Notice that createCounter() runs and returns, and yet the inner function it returns still has access to count. That persistent access, after the outer call is technically done, is the closure.

Typical Situations Where Closures Show Up

Closures matter whenever a function needs to hold on to some piece of data after the function that created it has already exited. You'll run into this pattern in several everyday scenarios:

  • Private variables – hiding data so outside code can't touch it directly.
  • Function factories – producing customized functions based on parameters.
  • Callbacks – capturing values so they're still available whenever the callback eventually fires.
  • Timers – reaching into variables from inside setTimeout or setInterval.
  • Event handlers – remembering data tied to a specific event.
  • React – making sense of callbacks, effects, and the "stale closure" problem.

Why They Matter

Closures let you hold onto state without leaning on global variables, which keeps your code cleaner and safer.

function createAccount(initialBalance) {
  let balance = initialBalance;
  return {
    deposit(amount) {
      balance += amount;
    },
    getBalance() {
      return balance;
    }
  };
}
const account = createAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance);      // undefined

In that example, balance can't be reached directly from outside the function. Only the object's methods, returned from createAccount, have access to it.

That structure buys you a few real advantages:

  • Better encapsulation
  • Genuinely private state
  • Simpler, more readable code
  • Reduced reliance on global variables

The Classic Loop Example

A go-to demonstration of closures in action is the difference between using var and let inside a loop that schedules callbacks.

Here's what happens with var:

for (var i = 1; i <= 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 1000);
}
// Output:
// 4
// 4
// 4

The output isn't what many people expect at first, and the reason is that var is function-scoped rather than block-scoped. All three setTimeout callbacks end up referring to the exact same variable i. By the time any of them actually runs, the loop has already completed, and i has already reached 4.

Now compare that with let:

for (let i = 1; i <= 3; i++) {
  setTimeout(() => {
    console.log(i);
  }, 1000);
}
// Output:
// 1
// 2
// 3

Because let is block-scoped, each pass through the loop gets its own independent binding of i. That means each callback closes over a different value, and you get the numbers printed in the order you'd naturally expect.

A Common Interview Exchange

Question: What is a closure in JavaScript?

A closure is a function that remembers and can still access variables from the scope it was defined in, even once the outer function has already finished executing.

Follow-up: Where would you actually use closures?

A solid answer covers practical usage rather than just the definition:

Closures show up most often in callbacks, timers, event handlers, and function factories. They're also useful whenever a piece of state needs to stay private instead of living in the global scope.

The Takeaway

The core idea boils down to one equation: a function plus the variables surrounding it equals a closure. If the backpack image sticks in your head—a function carrying its captured variables along wherever it's called—the whole concept becomes far less mysterious.

Making Sense of the Event Loop

Closures explain how a function can hold on to data across time. The event loop explains how JavaScript decides when to actually run code that isn't ready immediately—and that's where a lot of confusing console output comes from.

Nearly every JavaScript developer eventually runs into a version of this exact snippet, whether in an interview or by accident:

console.log("1");

setTimeout(() => {
  console.log("2");
}, 0);

console.log("3");

Reading this top to bottom, you'd expect the console to print 1, then 2, then 3 — the timeout is set to zero milliseconds, so there shouldn't be any delay at all.

But run it in a browser console or in Node, and the actual output looks like this:

1
3
2

So why does code scheduled to run "immediately" actually run after code written later in the file?

Answering that requires understanding the mechanism running underneath your code: the event loop. Rather than getting lost in formal vocabulary about execution contexts and scheduling algorithms, it helps to build a simple, durable mental model of what's actually happening.

The Core Contradiction at the Heart of JavaScript

Before diagrams and definitions, there's one fact you need to internalize:

JavaScript is strictly single-threaded.

Being single-threaded means JavaScript operates with exactly one call stack. It has one processing "brain" and can execute only one line of code at any given instant. It can't run two functions in parallel, and it can't split up a computation across multiple cores by itself.

That constraint raises an obvious question. Picture a piece of code that needs to wait three full seconds for a slow API to respond — with only one thread available and nothing else helping out, what would the rest of the page be able to do during that wait?

In a purely single-threaded environment with no help, the entire browser would seize up. You wouldn't be able to click anything, scroll, type into a field, or watch an animation play — everything would freeze until that network response finally arrived.

But that's not what actually happens. Modern web applications routinely stream video, fire off background network requests, quietly update analytics, and accept keyboard input all at the same time without locking up.

So how does a single-threaded language pull that off?

The answer: JavaScript itself is single-threaded, but the environment surrounding it — the browser or Node.js — is not.

A Restaurant Kitchen as a Mental Model

The clearest way to grasp this architecture is to imagine a busy fast-food kitchen:

[ Customer Orders ] ──> [ The Solo Chef ] (Call Stack)
                              │
                              ▼ Hands off slow jobs
                        [ The Kitchen Staff ] (Web APIs)
                              │
                              ▼ Ready order slips
                        [ Order Counter ] (Queues)
                              ▲
                              │ Checks constantly
                        [ The Floor Manager ] (The Event Loop)

Here's how the roles map out:

  1. The solo chef (the call stack). Fast and skilled, but able to work on exactly one order at a time — finishing it completely before starting the next.
  2. The kitchen assistants (Web APIs). When an order needs a long wait — a timer, or a slow response from a delivery driver — the chef doesn't stand around watching the clock. Instead, the chef hands the ticket to an assistant with instructions to keep watch over it and report back once it's ready, while the chef returns to the next task at hand.
  3. The order counter (the callback and microtask queues). Once the timer finishes, the assistant doesn't interrupt the chef mid-task by shoving the order into their hands. That would create chaos. Instead, the finished order gets placed on the counter to wait.
  4. The floor manager (the event loop). This manager continuously walks back and forth between the counter and the chef, following one strict rule: never interrupt the chef while they're working. The instant the chef's hands are completely free, the manager picks up the next ticket waiting on the counter and hands it over.

That's the whole system in miniature. With the restaurant picture in mind, it's time to swap those roles for their actual names in the JavaScript engine.

The engine names these four pieces differently, but each maps directly onto a role from the restaurant.

The diagram below sketches how the runtime environment (browser or Node.js) wraps around the JavaScript engine and connects these pieces together.

┌───────────────────────────────────────────────────────────┐
│                     BROWSER / RUNTIME                     │
│                                                           │
│   ┌─────────────────┐             ┌───────────────────┐   │
│   │   CALL STACK    │             │     WEB APIs      │   │
│   │ (JS Engine/V8)  │             │                   │   │
│   │                 │ ──────────> │  setTimeout()     │   │
│   │  fnA()          │             │  fetch()          │   │
│   │  console.log()  │             │  DOM Events       │   │
│   └────────┬────────┘             └─────────┬─────────┘   │
│            ▲                                │             │
│            │                                │ Finished    │
│            │                                ▼             │
│            │                      ┌───────────────────┐   │
│            │                      │  MICROTASK QUEUE  │   │
│            │                      │  (Promises, VIP)  │   │
│            │                      └─────────┬─────────┘   │
│            │                                │             │
│      EVENT LOOP                             ▼             │
│   "Is Stack empty?"               ┌───────────────────┐   │
│            └───────────────────── │  MACROTASK QUEUE  │   │
│                                   │ (Timers, Clicks)  │   │
│                                   └───────────────────┘   │
└───────────────────────────────────────────────────────────┘

The Call Stack — "whatever is happening right now"

The Call Stack is an ordinary LIFO (Last In, First Out) structure that the JavaScript engine itself manages — this is the same mechanism used inside V8 in Chrome and Node, or JavaScriptCore in Safari.

Every time you call a function, it gets added to the top of this stack. Once that function returns or finishes running, it gets removed from the top.

function stepThree() {
  console.log("Done!");
}

function stepTwo() {
  stepThree();
}

function stepOne() {
  stepTwo();
}

stepOne();

Tracing through a small chain of calls like this shows the mechanics clearly:

  • stepOne gets pushed onto the stack.
  • stepOne calls stepTwo, so stepTwo gets pushed on top.
  • stepTwo calls stepThree, so stepThree gets pushed on top of that.
  • stepThree runs console.log, which prints "Done!" and is then popped off.
  • stepThree finishes, so it gets popped.
  • stepTwo finishes, so it gets popped.
  • stepOne finishes, so it gets popped.
  • The stack returns to empty.

If any single function on this stack needs, say, ten straight seconds of heavy computation, the entire runtime freezes for those ten seconds. Nothing else can run until the stack clears — this is the direct consequence of the chef only having two hands.

Web APIs — where the "waiting" actually happens

Here's something that surprises a lot of people early in their careers: setTimeout, fetch, and DOM event listeners aren't features of the JavaScript language itself.

You won't find them anywhere in the ECMAScript specification. They're supplied by whatever environment is hosting your code — the browser, or in Node.js's case, a combination of C++ bindings and the libuv library.

Consider this call:

setTimeout(callback, 2000);

When the Call Stack executes that setTimeout invocation, all it actually does is tell the browser's internal timer mechanism to begin counting down 2000 milliseconds. As soon as that instruction is handed off, setTimeout itself is immediately removed from the Call Stack, and execution continues to whatever comes next in your code. The countdown itself proceeds outside of JavaScript's single thread entirely, in the host environment.

The Macrotask Queue — the regular line

This queue also goes by the name Callback Queue or Task Queue, depending on who you ask.

Once a Web API wraps up its background job — a timer reaches zero, a network response finishes arriving, a user clicks something — the host environment places the associated callback into this queue.

Everything sitting in this queue behaves politely: it's a standard FIFO (First In, First Out) line, with each item waiting its turn to be run on the Call Stack.

Typical examples of macrotasks include:

  • Callbacks scheduled by setTimeout() and setInterval()
  • DOM event listeners such as click, scroll, or keydown
  • setImmediate(), which is specific to Node.js

The Microtask Queue — the express lane

Running parallel to that regular queue is a smaller queue with much higher priority: the Microtask Queue.

Anything placed here effectively jumps to the front of the line. As soon as the Call Stack becomes empty, the event loop looks at the Microtask Queue first — before it even considers what's waiting in the Macrotask Queue.

Items that land in the Microtask Queue include:

  • Handlers attached through .then(), .catch(), or .finally() on Promises
  • The code that runs after an await inside an async function
  • Anything scheduled with queueMicrotask()
  • Callbacks fired by a MutationObserver

The Governing Rule Behind the Loop

Strip away the metaphors, and the event loop is really just an infinite while loop running inside the host environment, following five strict steps on repeat:

  1. Drain the Call Stack. Keep executing synchronous code until absolutely nothing remains on the stack.
  2. Fully empty the Microtask Queue. As long as there's a microtask waiting, run it. If running it produces yet another microtask, run that one too. The loop will not move on from this step until the microtask queue has nothing left in it.
  3. Handle rendering, if you're in a browser. The engine checks whether it's time to repaint the screen — normally around 60 times per second, roughly every 16.6 milliseconds — and if so, it recalculates layout and redraws.
  4. Execute exactly one macrotask. Look at the Macrotask Queue, grab whichever task has been waiting longest, push it onto the Call Stack, and let it run through to completion.
  5. Start the cycle over from step one.

The detail that trips people up is this asymmetry: microtasks get fully drained, every single one, before the loop moves forward — but macrotasks are handled strictly one at a time, with a full check of the stack and the microtask queue happening in between each one.

Walking Through a Well-Known Interview Puzzle

With the mechanics established, it's worth tracing a snippet that catches out a huge number of developers:

console.log("Start");

setTimeout(() => {
  console.log("Timeout Callback");
}, 0);

Promise.resolve().then(() => {
  console.log("Promise Callback");
});

console.log("End");

Following this line by line through the four components makes the surprising output make sense.

Synchronous pass first

console.log("Start") is pushed onto the stack, prints "Start", and pops off.

Next, setTimeout(...) is pushed onto the stack. It instructs the browser's Web API layer to begin a 0-millisecond timer with the given callback. Because the delay is zero, that timer expires essentially right away in the background, so the browser immediately places () => console.log("Timeout Callback") into the Macrotask Queue. The setTimeout call itself then pops off the stack.

After that, Promise.resolve().then(...) is pushed onto the stack. The promise resolves instantly, and its .then() handler — () => console.log("Promise Callback") — is placed directly into the Microtask Queue. This call then pops off the stack too.

Finally, console.log("End") is pushed, prints "End", and pops off.

Once the stack empties

At this point nothing remains on the Call Stack. The event loop wakes up and inspects its two queues: the Microtask Queue holds the Promise callback, and the Macrotask Queue holds the Timeout callback.

Microtasks go first

The loop checks the Microtask Queue before anything else. It finds the Promise callback, pushes it onto the stack, and "Promise Callback" gets printed. The stack empties again, and now the Microtask Queue is empty as well.

Then, one macrotask

With no microtasks left, the loop finally turns to the Macrotask Queue. It picks up the oldest entry — the Timeout callback — pushes it onto the stack, prints "Timeout Callback," and pops it off.

Putting it all together, the final printed sequence looks like this:

Start
End
Promise Callback
Timeout Callback

Even though the timeout was scheduled for 0 milliseconds and was written before the promise in the code, the promise callback printed first. This confirms the rule that microtasks always cut in line ahead of macrotasks, regardless of how small a delay you give a timer.

This leads to a common misunderstanding about what a delay value actually promises. Many beginners assume that setTimeout(fn, 1000) means "run this function exactly 1,000 milliseconds from now." That is not what happens. What it actually guarantees is that the callback will not be placed into the task queue until at least 1,000 milliseconds have passed. Once it lands in the queue, it still has to wait its turn until the Call Stack is empty and every pending microtask has been drained.

So if your Call Stack is tied up for five seconds running a heavy sort or processing an image, a setTimeout(fn, 100) callback will sit idle in the queue for the entire five seconds before the event loop can even glance at it. In short, a timeout delay is a minimum wait time, never a guaranteed firing time.

This queue-draining behavior has a more dangerous side effect worth understanding on its own. Because the event loop refuses to move on until the Microtask Queue is completely empty, it is possible to lock up your application without ever technically blocking the synchronous call stack. This failure mode is known as microtask starvation, sometimes called queue starvation.

Picture a function like this one:

function infiniteMicrotasks() {
  Promise.resolve().then(() => {
    infiniteMicrotasks();
  });
}

infiniteMicrotasks();

Walking through what happens: the initial call returns and the stack clears. The event loop then checks the Microtask Queue and finds an entry. It runs that entry, but running it immediately schedules another microtask. The event loop checks the queue again, finds it still occupied, and runs the next one — which schedules yet another. The stack keeps emptying after each microtask, but the event loop never escapes this checking step. It never advances far enough to let the browser repaint the UI or to reach anything waiting in the Macrotask Queue.

The practical outcome is that the page appears completely frozen. Clicks are ignored, animations stop advancing, and eventually the browser may flag the tab with an unresponsive script warning.

To summarize how each kind of work is scheduled and prioritized across the runtime, it helps to look at a side-by-side breakdown of where synchronous code, microtasks, macrotasks and rendering each fit into the cycle, as shown in the accompanying diagram.

Grasping how the event loop operates is not just useful trivia for interviews — it directly shapes how you should design real applications, and three practical habits follow from it.

First, break up expensive synchronous loops. If you need to process an array with, say, 500,000 entries on the main thread, avoid doing it in one uninterrupted for loop, since that will freeze the interface. Split the work into chunks and hand control back to the event loop between chunks using setTimeout(chunkHandler, 0) or requestIdleCallback(). That gives the browser room to handle clicks and repaint frames in between chunks.

Second, be precise about what async/await actually does under the hood. It is syntactic sugar layered over promises: any code written before the first await executes synchronously, while everything following an await is wrapped into a microtask callback that stays paused until the awaited promise settles.

Third, never treat timers as exact stopwatches. If you're building something like an in-app timer or an audio sequencer, don't rely on setInterval or setTimeout to hit precise millisecond marks. System load, browsers throttling background tabs to save battery, and congestion in the queues will all introduce drift. Instead, measure elapsed time against the system clock using performance.now().

JavaScript running on a single thread isn't a limitation to work around — it's one of the language's strongest design choices. By pairing one fast synchronous engine with a set of background browser APIs and a tightly managed event loop, JavaScript creates the experience of doing many things at once, all while sidestepping the race conditions, deadlocks and thread-safety headaches that come with true multithreading in other languages.

Once you understand and respect how the queues work, the way your code executes will stop being a surprise.