CHAPTER 03 · LANGUAGE

The JavaScript Runtime Model

TypeScript only adds types to JavaScript; how the program runs is decided entirely by JavaScript's runtime rules. This is one of the most important "Deep" chapters: async ordering, concurrency, closures, references — most of the subtle bugs agents write hide here.

⏱ ~60 minDeepThe syntax cheat sheet is Use
By the end of this chapter you should be able to:
  • Predict the output order of code mixing setTimeout, Promise and await, and explain why.
  • Explain how Node serves thousands of concurrent requests with one thread — and what code "freezes everyone".
  • Recognize and fix three common async bugs: forEach(async), a missing await, needless serialization.
  • Explain closures and reference equality, setting up Chapter 06 on React.

Opening: one thread, a thousand requests

Opening question

Traditional Java web servers give each request a thread; Go gives each request a goroutine. Node.js executes your JavaScript on a single thread. How can it serve thousands of requests at once? And why can one CPU-heavy for loop freeze every user simultaneously?

Where the answer lies: web servers spend most of their time waiting — for the database, the network, the disk. JavaScript's approach: waiting doesn't occupy the thread. Start the I/O, immediately go do something else, and queue up the continuation when the result arrives. But computation does occupy the thread: while it computes, everyone in the queue waits. Understand those two sentences and you understand half this chapter.

One thread + non-blocking I/O

Thread pool (classic Java) Event loop (Node / browser) thread 1 thread 2 thread 3 …1000 requests ≈ 1000 threads (mostly waiting) each thread has its own stack (MBs of memory) while waiting, the thread is held but idle running code waiting on I/O (idle) main A1 B1 C1 A2 C2 B2 system OS / libuv wait on all I/O concurrently Request A's first half (A1) starts a DB query and yields at once; when the result returns, A's second half (A2) is queued But if B1 is a loop that runs for 2 seconds, A2, C2 and every new request wait 2 seconds
Left: each request owns a thread that idles while waiting. Right: one thread takes turns running each request's "non-waiting slices"; the waiting is done concurrently by the system layer.
Analogy: a restaurant with one waiter

The thread-pool model gives every table its own waiter, who takes the order and then stands at the kitchen door waiting for the food. The event-loop model has a single waiter: take table A's order, hand the ticket to the kitchen (system-level I/O), immediately go take table B's order; when the kitchen shouts "table A is ready", note it on the to-do queue and serve it once the current task is done.

Where the analogy breaks: if that single waiter starts cooking (CPU-heavy work), the whole restaurant stops — exactly why one big loop blocks every request. And in reality the "kitchen" is finite too: when the DB connection pool is full, you queue anyway.

What this means for you:

The event loop: just three rules

How exactly does "queue up the continuation" work? JavaScript has two kinds of queues:

The event loop's rules:

  1. Take one task from the task queue and run it until the call stack is empty (sync code runs to completion, never interrupted).
  2. Drain the entire microtask queue (microtasks added while draining also run in this round).
  3. (Browser) render the page if needed. Go back to step 1.

Here's a classic snippet, step by step. Click "Next" and watch the call stack and both queues at every step, not just the final output:

Two things in this demo are counter-intuitive: setTimeout(fn, 0) doesn't run "immediately" — it waits behind every microtask; and an async function runs synchronously until its first await (which is why D prints before F).

Promises and async/await

What a Promise is

A Promise is a container object for a "future value" with exactly three states: pending → fulfilled (with a value) or rejected (with an error). Once settled, it never changes. If you've used Java's CompletableFuture or C#'s Task, the concept is nearly identical.

async/await is syntactic sugar over Promises:

The three most common async bugs Deep

None of these throw errors; they just make the program "occasionally wrong". Agents write them often enough that you must recognize them on sight.

Bug 1: async inside forEach

Why: forEach calls the callback and throws away its return value (a Promise); it has no idea it should wait.

Bug 2: the missing await (a "floating promise")

Consequences: you report success before the data is written; if the insert fails, the error becomes an "unhandled promise rejection" that can crash the Node process, or the work gets frozen mid-flight in a serverless environment because the function already returned. Defense: turn on the lint rule no-floating-promises (from typescript-eslint; Biome has an equivalent).

Bug 3: needless serialization

This is especially common in server-rendered pages, forming a "request waterfall" — you'll meet it again in Chapter 07.

Tools for combining Promises

APIBehaviorUse when
Promise.allSucceeds only if all succeed; fails as soon as one failsParallel requests that are all required
Promise.allSettledWaits for all, then reports each success or failureBatches that tolerate partial failure (fetching 10 links)
Promise.raceThe first to settle (either way) decidesHand-rolled timeouts (AbortSignal is preferred now)
Promise.anyThe first to succeed decidesTake the fastest of several mirrors
Concurrency limits

Promise.all(1000 requests) fires 1000 requests at once, which can blow through the other side's rate limit or your own connection pool. When you need a limit, use a small library like p-limit or process in batches. When giving an agent a task, state the concurrency limit as an explicit constraint.

Cancellation: AbortController

JS Promises can't be cancelled on their own. The standard approach is to pass an AbortSignal: fetch and many SDKs (LLM SDKs included) accept one. A user closing the page, changing the search term, or a timeout should all trigger cancellation to avoid wasting resources — and with LLM calls, the waste is real money.

Values, references and equality

In JS, primitive values (string, number, boolean, null, undefined, bigint, symbol) compare by value; objects, arrays and functions are references, and === asks "is this the same object?", not "is the content equal?". That matches Java's == on objects.

Why dwell on this? Because React decides whether state changed by comparing references. Mutate an array in place (list.push(x)) instead of creating a new one, and React concludes nothing changed — the UI doesn't update. This is a core pitfall in Chapter 06.

Note that spread is a shallow copy: nested objects are still shared. For a deep copy, use the built-in structuredClone(obj).

Closures

A function "remembers" the variables in the scope where it was created, even after the outer function returns. That's a closure. Java lambdas can capture outer variables too (as long as they're effectively final); Go's anonymous functions behave almost identically.

Closures themselves are easy. The real-world trap is the stale closure: a function captures the variables that existed at the moment it was created. If new variables are created later (React creates new state variables on every render), the old function still sees the old values. Chapter 06 shows a concrete example.

Modules: import / export

Modern JS uses ES modules (ESM):

Things you need to know:

Syntax cheat sheet for experienced developers Use

These appear everywhere in agent-generated code. No need to memorize them, but you should recognize them instantly:

SyntaxMeaningAnalogy
const / letNot reassignable / reassignable (don't use var)Java final / plain variable
(x) => x * 2Arrow functionJava lambda
const { id, url } = linkDestructuring: pull fields out of an objectPython tuple unpacking
const [first, ...rest] = arrArray destructuring + restPython first, *rest
{ ...a, b: 1 }Spread: shallow copy with overridesPython {**a, "b": 1}
`Hi ${name}`Template literalPython f-string
a?.b?.cOptional chaining: returns undefined on null/undefinedKotlin ?.
a ?? bUse b when a is null/undefinedKotlin ?:
a || bUse b when a is "falsy" (0 and "" count!)—
arr.map / filter / reduceFunctional operations returning new arraysJava Streams
{ url }Property shorthand for { url: url }—
Two pieces of historical baggage
  • Always use ===, never ==. == does implicit coercion: "0" == false is true.
  • Know ?? from ||. count || 10 yields 10 when count is 0 — usually a bug. For "default only when missing", use ??.

On this and class: Aware modern TS full-stack code is mostly functions and plain objects; class isn't used much (React components dropped classes long ago). Just know that this in a regular function depends on how it's called, while arrow functions have no this of their own and use the outer one. When you hit a related bug, asking the agent to switch to an arrow function or pass the value explicitly usually solves it.

Where this lands in LinkNote

A user imports 20 links at once. Requirements: fetch and summarize each; at most 3 at a time; one failure doesn't affect the rest; any single link taking over 20 seconds counts as a timeout; report success/failure counts when everything finishes.

Map them to this chapter: "at most 3" = a concurrency limit; "one failure doesn't affect the rest" = Promise.allSettled; "20-second timeout" = AbortSignal.timeout(20_000); "report when everything finishes" = you must await the aggregate result, not use forEach(async).

Tell the agent
Vague
Precise
"Bulk import is sometimes off, fix it."
"importLinks uses forEach(async) on line 42, so the function returns before processing finishes. Switch to parallel processing with a concurrency limit of 3, collect results with Promise.allSettled, add a 20-second timeout per task (AbortSignal), and return { ok: number; failed: { url: string; reason: string }[] }. Write one test for a timeout and one for partial failure."

The precise version uses this chapter's vocabulary: concurrency limit, allSettled, AbortSignal, timeout. With the right words, the agent doesn't have to guess your intent.

Interview corner

Q: What's the difference between tasks and microtasks? What does this print? (a variant to test yourself)

Answer framework: state the rules first (sync code → drain microtasks, including ones added while draining → take one task), then trace line by line. Output: 5 2 4 1 3. 4 comes before 1 because the microtask queue is fully drained in this round; 3 comes after 1 because it joined the task queue later.

Self-check

1. In the demo, replace await null with await new Promise(r => setTimeout(r, 0)). Which prints first, E or B?

B. The second half containing E now continues only after a setTimeout fires, and that timer was registered after B's timer; both are 0ms, so they queue in order. The order becomes A D F C B E. The key is tracking "which queue the remaining code is hanging on".

2. In a Node API service, one endpoint synchronously parses a 50MB JSON file (about 800ms). What happens to other endpoints?

For those 800ms the main thread is busy; no other request — even one that returns a constant — can be handled, so everyone's latency grows by ~800ms. Fixes: stream the parsing, move it to a worker thread, preprocess and cache, or push it to a background job.

3. This code wants to "add an item and notify the UI". Why might the UI not update? items.push(newItem); setItems(items);

push mutates the original array, so items is still the same reference. React compares references to detect state changes, sees "the same array", and assumes nothing changed. Write setItems([...items, newItem]) to create a new array.

4. const limit = Number(params.limit) || 20; — what happens when the user passes limit=0? How should it be written?

0 is falsy, so limit becomes 20. If 0 is valid, check for missing first (params.limit == null or ??). Better still, parse and validate params with Zod in one place (Chapter 04).