Skip to content
Back

Core JavaScript Concepts

Frontend

How the call stack, event loop, task queues, closures, promises, and generators fit into one JavaScript runtime model

Synchronous code runs on the call stack. The host handles work off that stack. The event loop coordinates when queued callbacks can run. Closures retain lexical state. Promises, async/await, and generators pause and resume work.

This note is when code runs. class, prototype, and this are TypeScript Classes and Runtime Identity. Queues and the 5 1 3 4 2 puzzle: The JavaScript Event Loop in Depth.



  • Synchronous code runs on the stack.
  • Host APIs finish work off the stack and enqueue a callback.
  • When the stack is empty, the loop empties the microtask queue, then moves one task onto the stack.

1. Event Loop

Each browser iteration generally runs one task, drains all microtasks, may render, then takes another task.


ts
log.push("sync start")
setTimeout(() => log.push("macrotask"), 0)
Promise.resolve().then(() => log.push("microtask"))
log.push("sync end")
// ["sync start", "sync end", "microtask", "macrotask"]

  • JavaScript does not interrupt the code currently on the stack.
  • A timer may finish in the background; its callback waits for the current task and its microtasks.
  • Failure: a zero-millisecond timer is still not immediate. The delay is a lower bound on a timer task.

2. Call Stack

The call stack is a last-in, first-out record of what the engine is currently executing. Calling a function pushes a frame; returning or throwing pops it. Only the top frame runs.


ts
function factorialRecursive(n: number): number {
  if (n <= 1) return 1
  return n * factorialRecursive(n - 1)
}

function factorialIterative(n: number): number {
  let result = 1
  for (let i = 2; i <= n; i++) result *= i
  return result
}

  • Deep recursion consumes a frame per call until the engine hits its limit.
  • ES2015 specified proper tail calls; among major engines only JavaScriptCore implemented them. Recursion depth remains a practical limit.
  • Failure: a tight loop does not share the thread. It owns it — no paint, clicks, or timers until it returns.

3. Macrotask Queue

Macrotask is informal; the HTML spec usually says tasks. Timers, user interactions, and messages enqueue work. Browsers may keep several queues and select a runnable task, so one global FIFO is a cartoon.

  • The loop runs one selected task, then a microtask checkpoint, then another task.
  • A nested setTimeout schedules future work. Typical order: timeout-1 → timeout-3 → timeout-2.
  • Failure: treating setTimeout(fn, 0) as "before the next line." It means "as soon as a timer task is allowed."

4. Microtask Queue

Microtasks — Promise handlers, queueMicrotask, MutationObserver — are the higher-priority queue on the same thread. After a task finishes and the stack is empty, the runtime drains them until the queue is empty.

  • A microtask that queues another microtask adds work to the same checkpoint. Typical order: micro-1 → promise → micro-2 → macrotask.
  • That is why a promise handler runs before a ready setTimeout.
  • Failure: an unbounded microtask chain delays timers, input, and rendering indefinitely — a freeze without a while.

5. Execution Context

An execution context is the environment created when JavaScript enters global code or calls a function: bindings, outer lexical environment, and this.


ts
const obj = {
  label: "object",
  regular(this: { label: string }): string {
    const arrow = (): string => this.label
    return arrow()
  },
}

obj.regular() // "object" — arrow captures the method's `this`

  • Each call gets its own context and stack frame.
  • When the function returns, the frame is removed, but the lexical environment stays reachable if a returned function closes over it.
  • Arrow functions do not create their own this; they capture it from the surrounding context.

6. Closures

A closure is not a special kind of function. A function carries access to the lexical environment where it was created, even after the outer function has returned.


ts
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.value() // 11 — `count` is private to the closure

  • Private state, event handlers, and many hook patterns are closures.
  • Captured objects stay reachable. A callback that closes over a large tree can leak it.
  • Failure: callbacks created in a loop with var share one function-scoped binding. let creates a new binding per iteration. A closure is a live environment, not a copy of values.

7. Promises

A Promise is pending, then fulfilled or rejected. Calling .then, .catch, or .finally does not run the handler immediately; a settled promise queues it as a microtask.


ts
function delay<T>(value: T, ms: number): Promise<T> {
  return new Promise((resolve) => setTimeout(() => resolve(value), ms))
}

const user = delay({ name: "Ada" }, 100)
const settings = delay({ theme: "dark" }, 50)

Promise.all([user, settings]).then(([u, s]) => {
  // "Ada", "dark" — after ~100 ms, not 150 ms
  console.log(u.name, s.theme)
})

  • Promise.all fails fast. Promise.allSettled records every outcome. Promise.race settles with the first result.
  • Start independent promises before awaiting them so the underlying work overlaps.
  • Failure: wrapping a timer in a Promise does not make the timer a microtask. setTimeout still schedules a task; .then runs as a microtask after that task.

8. Async/Await

async/await is syntax on promises, not a second concurrency system. An async function always returns a promise. Reaching await yields; after the promise settles, the rest continues as a microtask.


ts
async function loadDashboard(userId: string) {
  const userPromise = getJson<User>(`/api/users/${userId}`)
  const settingsPromise = getJson<Settings>("/api/settings")
  const [user, settings] = await Promise.all([userPromise, settingsPromise])
  return { user, settings }
}

  • await does not block the JavaScript thread and does not make JavaScript multi-threaded. It pauses that async function. Even await Promise.resolve() yields.
  • A response.json() as T assertion describes the expected shape; it does not validate it.
  • Failure: sequential await of independent fetches is a waterfall. Start them together, then Promise.all.

9. Generators

A generator (function*) does not run its body when called. It returns an iterator. Each .next() resumes until the next yield or until the function returns.


ts
function* range(start: number, end: number): Generator<number> {
  for (let i = start; i < end; i++) yield i
}

const values = range(0, 3) // body has not run
values.next() // { value: 0, done: false }
values.next() // { value: 1, done: false }
// 2 is not produced unless another value is requested.

  • Values are produced only when requested — a lazy sequence, not a one-shot eventual result.
  • An async generator makes .next() return a promise; for await...of waits for each result. That fits paginated responses and streams.
  • Failure: treating yield as await in a non-async generator. Promises coordinate one eventual value. Generators coordinate a sequence of pauses.

Takeaway

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. Promises, async/await, and generators organize work that pauses and resumes.

When async code surprises: What is on the stack? What is queued as a microtask? What must wait for a later task?


Recap Q&A