How JavaScript Works
JavaScript feels simple to write, but a lot happens between the moment you save a file and the moment your code produces output. Understanding the engine — how it parses, compiles, and runs your code on a single thread, and how it stays responsive despite that single thread — turns mysterious bugs into predictable behavior. This page builds the mental model that the deeper pages on scope, closures, and the event loop assume you already have.
The runtime is more than the engine
When people say “JavaScript,” they usually mean two things working together: the engine that executes your code, and the runtime that surrounds it with extra capabilities.
The engine (V8 in Chrome and Node.js, SpiderMonkey in Firefox, JavaScriptCore in Safari) knows nothing about timers, the DOM, or HTTP. It only understands the language itself — values, functions, objects, and the call stack. Everything else is provided by the host environment:
| Capability | Provided by | Example |
|---|---|---|
Core language (objects, closures, Promise) | The engine | Array.prototype.map |
DOM, fetch, timers | Browser Web APIs | document.querySelector, setTimeout |
| File system, networking, timers | Node.js APIs (libuv) | fs.readFile, setTimeout |
This split is why setTimeout and fetch exist in both browsers and Node even though they are not part of the JavaScript language specification.
From source text to execution
Before a single line runs, the engine processes your source in stages:
- Parsing — the source text is tokenized and turned into an Abstract Syntax Tree (AST), a structured representation of your program. Syntax errors are caught here, before anything executes.
- Compilation — modern engines use a Just-In-Time (JIT) compiler. The AST is first turned into bytecode by an interpreter (V8’s Ignition), so execution can start quickly.
- Optimization — as the program runs, a profiler watches which functions run often (“hot” code). Those are recompiled into highly optimized machine code by an optimizing compiler (V8’s TurboFan). If an assumption later proves wrong (e.g. a number suddenly becomes a string), the engine deoptimizes and falls back to bytecode.
This is why a loop can speed up after its first thousand iterations: the hot path got compiled to machine code mid-run.
The call stack and the memory heap
The engine has two core structures:
- Memory heap — an unstructured region where objects, arrays, and functions are allocated. A garbage collector reclaims memory that is no longer reachable.
- Call stack — a LIFO (last-in, first-out) record of where the program is in its execution. Each function call pushes a stack frame; returning pops it.
JavaScript runs on a single thread, which means it has exactly one call stack and can do exactly one thing at a time.
function multiply(a, b) {
return a * b;
}
function square(n) {
return multiply(n, n);
}
function printSquare(n) {
const result = square(n);
console.log(result);
}
printSquare(5);
Output:
25
The stack grows and shrinks like this as the code above runs:
push printSquare(5)
push square(5)
push multiply(5, 5) -> returns 25, pop
square returns 25, pop
push console.log(25) -> prints 25, pop
printSquare returns, pop -> stack empty
If the stack never empties — for example, a function that calls itself with no base case — you exhaust the available frames and get a
RangeError: Maximum call stack size exceeded.
Single thread, but not blocking
A single thread sounds limiting: if one task takes three seconds, nothing else can run during that time. A long synchronous loop genuinely does freeze the page. The trick is that slow operations — network requests, timers, file reads — are not run on the JavaScript thread at all. They are handed off to the host environment, which does the waiting elsewhere and notifies JavaScript when the result is ready.
That handoff and notification is coordinated by the event loop.
The event loop, briefly
Around the engine sit three more pieces:
- Web APIs / Node APIs — handle async work (timers, network) off the main thread.
- Task queue (macrotask queue) — holds callbacks ready to run, e.g. from
setTimeout. - Microtask queue — holds resolved-promise callbacks; it has higher priority than the task queue.
The event loop’s rule is simple: when the call stack is empty, drain all microtasks, then take one task from the task queue and run it — repeat forever.
┌───────────────────────────┐
│ Call Stack │ <- runs JS, one frame at a time
└─────────────┬─────────────┘
│ when empty
▼
┌───────────────────────────┐
│ Microtask queue │ <- Promises (drained fully first)
└─────────────┬─────────────┘
▼
┌───────────────────────────┐
│ Task / macrotask queue │ <- setTimeout, I/O (one per tick)
└───────────────────────────┘
▲
│ callbacks pushed when async work finishes
┌───────────────────────────┐
│ Web APIs / libuv (off │
│ the main thread) │
└───────────────────────────┘
This ordering explains a classic surprise:
console.log("1: sync");
setTimeout(() => console.log("2: timeout"), 0);
Promise.resolve().then(() => console.log("3: promise"));
console.log("4: sync");
Output:
1: sync
4: sync
3: promise
2: timeout
Both synchronous logs run first because they execute directly on the stack. Then the stack empties: the microtask (the promise) runs before the macrotask (the timeout) — even though the timeout was scheduled first — because microtasks always drain before the next task.
A
setTimeout(fn, 0)does not run “immediately.” It runs after the current synchronous code and all pending microtasks. Zero is a minimum delay, not a guarantee.
Best practices
- Keep synchronous work short; the single thread means a slow loop blocks rendering, input, and every pending callback.
- Reach for
async/awaitover manual promise chains — it reads sequentially while still yielding to the event loop. - Remember that
Promisecallbacks (microtasks) always run beforesetTimeoutcallbacks (macrotasks), regardless of scheduling order. - Treat
setTimeout(fn, 0)as “run as soon as the stack and microtasks are clear,” not as instant execution. - Offload genuinely heavy computation to Web Workers (browser) or worker threads (Node) so the main thread stays responsive.
- Avoid uncontrolled recursion; convert deep recursion to iteration to stay clear of stack overflow errors.