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.
- Predict the output order of code mixing
setTimeout,Promiseandawait, 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 missingawait, needless serialization. - Explain closures and reference equality, setting up Chapter 06 on React.
Opening: one thread, a thousand requests
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
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:
- I/O-bound services (querying DBs, calling APIs, calling LLMs) run very efficiently on Node — and that's the shape of most web and AI apps.
- CPU-bound work (image processing, parsing huge JSON, heavy computation) should be chunked, moved to a worker thread, or handed to a dedicated service or queue. On Cloudflare Workers, CPU time also has an explicit limit.
- Same in the browser: the main thread also renders the page and responds to clicks. Compute too long in JS and the page "freezes".
The event loop: just three rules
How exactly does "queue up the continuation" work? JavaScript has two kinds of queues:
- Task queue (macrotasks): timer callbacks (
setTimeout), I/O completion callbacks, user events (clicks), etc. - Microtask queue: Promise continuations (
.thencallbacks, code afterawait).
The event loop's rules:
- Take one task from the task queue and run it until the call stack is empty (sync code runs to completion, never interrupted).
- Drain the entire microtask queue (microtasks added while draining also run in this round).
- (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:
- An
async functionalways returns a Promise. await psuspends the current async function (without blocking the thread) and registers "the rest of the code" as a microtask for when p settles.- If the awaited Promise rejects, it throws at that spot, catchable with
try/catch.
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
| API | Behavior | Use when |
|---|---|---|
Promise.all | Succeeds only if all succeed; fails as soon as one fails | Parallel requests that are all required |
Promise.allSettled | Waits for all, then reports each success or failure | Batches that tolerate partial failure (fetching 10 links) |
Promise.race | The first to settle (either way) decides | Hand-rolled timeouts (AbortSignal is preferred now) |
Promise.any | The first to succeed decides | Take the fastest of several mirrors |
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:
- A module executes once: its top-level code runs on first import; every later import gets the same exports. This is often used for singletons, e.g. one database client for the whole app.
- CommonJS (CJS) is Node's old module system (
require/module.exports). You'll still see it in older libraries and tutorials. Mixing the two is the source of errors likeERR_REQUIRE_ESM. New projects should use ESM ("type": "module"). Aware - Stylistically, most TS projects prefer named exports: renames and auto-imports are more reliable. Some framework conventions require default exports (e.g. Next.js
page.tsx).
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:
| Syntax | Meaning | Analogy |
|---|---|---|
const / let | Not reassignable / reassignable (don't use var) | Java final / plain variable |
(x) => x * 2 | Arrow function | Java lambda |
const { id, url } = link | Destructuring: pull fields out of an object | Python tuple unpacking |
const [first, ...rest] = arr | Array destructuring + rest | Python first, *rest |
{ ...a, b: 1 } | Spread: shallow copy with overrides | Python {**a, "b": 1} |
`Hi ${name}` | Template literal | Python f-string |
a?.b?.c | Optional chaining: returns undefined on null/undefined | Kotlin ?. |
a ?? b | Use b when a is null/undefined | Kotlin ?: |
a || b | Use b when a is "falsy" (0 and "" count!) | — |
arr.map / filter / reduce | Functional operations returning new arrays | Java Streams |
{ url } | Property shorthand for { url: url } | — |
- Always use
===, never==.==does implicit coercion:"0" == falseis true. - Know
??from||.count || 10yields 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.
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).
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.
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).