It is possible to use promises and async/await every day without a clear picture of what the runtime is doing underneath. These notes revisit JavaScript's execution model from the inside out.
The concepts form one system: synchronous code runs on the call stack, the host environment handles work outside that stack, and the event loop coordinates when queued callbacks can run. Closures explain how functions retain state, while promises, async/await, and generators provide different ways to pause and resume work.
The browser event loop at a glance: synchronous code runs on the call stack, host APIs complete work in the background and enqueue tasks, and promise reactions join the higher-priority microtask queue. Whenever the stack empties, the event loop pushes the next callback onto it—microtasks first, then one task at a time.
1. Event Loop
A common first impression is that the event loop simply checks whether asynchronous work has finished. The more useful model is that each browser event-loop iteration generally runs one task, drains all microtasks, gives the browser an opportunity to render, and then moves to another task.
The important consequence is that JavaScript does not interrupt the code currently on the stack. A timer may finish in the background, but its callback has to wait until the current task and its microtasks are done. This is why a zero-millisecond timer still does not run immediately.
const log: string[] = []
log.push("sync start")
setTimeout(() => {
log.push("macrotask")
}, 0)
Promise.resolve().then(() => {
log.push("microtask")
})
log.push("sync end")
// After the timer runs:
// ["sync start", "sync end", "microtask", "macrotask"]2. Call Stack
The call stack is JavaScript's record of what it is currently doing. It is a last-in, first-out structure: calling a function pushes a frame, and returning or throwing removes that frame. Only the frame at the top can execute.
This explains why deeply recursive code can fail even when the algorithm is logically correct. Every recursive call consumes another stack frame until the engine reaches its limit. For input with unbounded depth, iteration or another constant-stack technique is the safer choice. ES2015 specified proper tail calls, which would make some recursive shapes constant-space, but among major engines only JavaScriptCore implemented them—recursion depth remains a practical limit.
function factorialRecursive(n: number): number {
if (!Number.isInteger(n) || n < 0) {
throw new RangeError("n must be a non-negative integer")
}
if (n <= 1) return 1
return n * factorialRecursive(n - 1)
}
function factorialIterative(n: number): number {
if (!Number.isInteger(n) || n < 0) {
throw new RangeError("n must be a non-negative integer")
}
let result = 1
for (let i = 2; i <= n; i++) {
result *= i
}
return result
}
// Same result; iterative keeps O(1) stack frames
factorialRecursive(5) // 120
factorialIterative(5) // 1203. Macrotask Queue
Macrotask is common terminology, while the HTML specification usually calls these simply tasks. Timers, user interactions, and messages can place work into task queues. Browsers may maintain multiple queues and choose which runnable task to process next, so one global FIFO queue is only a simplified mental model.
The rule to keep in mind is that the event loop runs one selected task, then performs a microtask checkpoint before selecting another task. A nested setTimeout therefore schedules future work; it cannot continue inside its parent's current turn.
const order: string[] = []
setTimeout(() => {
order.push("timeout-1")
setTimeout(() => {
order.push("timeout-2")
}, 0)
}, 0)
setTimeout(() => {
order.push("timeout-3")
}, 0)
// Typical order: timeout-1 → timeout-3 → timeout-24. Microtask Queue
Microtasks are the piece most often missing from an initial understanding of asynchronous JavaScript. Promise handlers, queueMicrotask, and MutationObserver callbacks use this higher-priority queue. After a task finishes and the stack is empty, the runtime drains microtasks until the queue is empty.
This explains why promise handlers run before a ready setTimeout callback. It also carries a risk: a microtask can enqueue another microtask, so an unbounded chain can delay timers, user input, and rendering indefinitely.
const order: string[] = []
setTimeout(() => {
order.push("macrotask")
}, 0)
queueMicrotask(() => {
order.push("micro-1")
queueMicrotask(() => {
order.push("micro-2")
})
})
Promise.resolve().then(() => {
order.push("promise")
})
// Final order after the timeout runs:
// micro-1 → promise → micro-2 → macrotask5. Execution Context
An execution context connects the call stack with scope. When JavaScript enters global code or calls a function, it creates the environment needed to execute that code: its bindings, outer lexical environment, and this value.
Each function call gets its own context and stack frame. When the function returns, the frame is removed, but its lexical environment can remain reachable when a returned function closes over it. Arrow functions add a useful distinction: they do not create their own this; they capture it from the surrounding context.
const obj = {
label: "object",
regular(this: { label: string }): string {
const arrow = (): string => this.label
return arrow()
},
}
const globalLabel = "global"
function outer(prefix: string) {
const local = "inner"
function inner(): string {
// Scope chain: inner → outer → global
return `${prefix}:${local}:${globalLabel}`
}
return inner
}
// The regular method receives `obj` as `this`, and its arrow captures it.
obj.regular() // "object"
// The returned function retains `prefix` and `local`.
outer("ctx")() // "ctx:inner:global"6. Closures
A closure is easiest to grasp as something other than a special type of function. A function naturally carries access to the lexical environment where it was created, even if it is called after the outer function has returned.
This is what makes private state, event handlers, and many hook patterns possible. It pays to be deliberate about what a closure retains: captured objects stay reachable, and callbacks created in a loop with var share one function-scoped binding. A let loop variable creates a new binding for each iteration and avoids that common trap.
function createCounter(start = 0) {
let count = start
return {
inc(): number {
count += 1
return count
},
value(): number {
return count
},
}
}
const counter = createCounter(10)
counter.inc() // 11
counter.inc() // 12
counter.value() // 12 — `count` is private to the closure7. Promises
A Promise represents the eventual result of an operation. It begins as pending and becomes either fulfilled or rejected. One easily overlooked detail is that calling .then, .catch, or .finally does not run the handler immediately; a settled promise queues that handler as a microtask.
Promise combinators are easier to choose when treated as different coordination strategies: Promise.all fails fast, Promise.allSettled records every outcome, and Promise.race settles with the first result. Starting several promises before awaiting them allows the underlying operations to overlap.
type User = { id: string; name: string }
type Settings = { theme: "light" | "dark" }
function delay<T>(value: T, milliseconds: number): Promise<T> {
return new Promise((resolve) => {
setTimeout(() => resolve(value), milliseconds)
})
}
// Both timers start immediately; neither waits for the other.
const userPromise = delay<User>({ id: "1", name: "Ada" }, 100)
const settingsPromise = delay<Settings>({ theme: "dark" }, 50)
Promise.all([userPromise, settingsPromise]).then(([user, settings]) => {
console.log(user.name, settings.theme)
// "Ada", "dark" — after roughly 100 ms, not 150 ms
})8. Async/Await
async/await is syntax built on promises rather than a separate concurrency system. An async function always returns a promise. When it reaches await, that function yields control; after the awaited promise settles, the rest of the function is queued to continue as a microtask.
await does not block the JavaScript thread, and it does not make JavaScript multi-threaded. It only pauses that async function. Independent operations should start together rather than be awaited one at a time.
type User = { id: string; name: string }
type Settings = { theme: "light" | "dark" }
async function getJson<T>(url: string): Promise<T> {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
// This assertion describes the expected shape; it does not validate it.
return (await response.json()) as T
}
async function loadDashboard(userId: string) {
// Start both independent requests before waiting.
const userPromise = getJson<User>(`/api/users/${userId}`)
const settingsPromise = getJson<Settings>("/api/settings")
const [user, settings] = await Promise.all([
userPromise,
settingsPromise,
])
return { user, settings }
}9. Generators
A generator (function*) is another angle on suspended execution. Calling a generator does not immediately run its body. It returns an iterator, and each .next() resumes execution until the next yield or until the function returns.
This makes generators useful for lazy sequences because values are produced only when requested. With an async generator, .next() returns a promise for an iterator result, and for await...of waits for each result. That pattern is useful when values arrive over time, such as paginated responses or streams.
function* range(start: number, end: number): Generator<number, void, unknown> {
for (let i = start; i < end; i++) {
console.log(`producing ${i}`)
yield i
}
}
const values = range(0, 3) // logs nothing: the body has not run
values.next() // logs "producing 0"; { value: 0, done: false }
values.next() // logs "producing 1"; { value: 1, done: false }
// 2 is not produced unless another value is requested.Takeaway
These are not isolated JavaScript features. The call stack and execution contexts explain what is running. Task queues, microtasks, and the event loop explain when deferred work can run. Closures explain why state remains reachable, while promises, async/await, and generators provide different ways to organize work that pauses and resumes.
When async code behaves unexpectedly, three questions resolve most cases: What is currently on the stack? What will be queued as a microtask? What must wait for a later task? That mental model is more useful than memorizing the output of individual event-loop examples.