DevelopmentAll levels

JavaScript

JavaScript is the language every browser runs, and the one most people learn syntax-first and semantics-never. Almost everything that seems arbitrary about it follows from one fact: your code runs on a single thread with one call stack, and everything asynchronous is a queue waiting for that stack to empty. This page covers the event loop, references, closures, modules, and where async error handling silently fails.

Threads
One, per context
Queues
Micro and macro
Standard
ECMAScript, yearly

One thread, two queues

JavaScript in a browser tab runs on one thread. There is one call stack, and while a function is on it nothing else in that tab runs — no other handler, no timer callback, and no painting. That last part is what "blocking the UI" literally means: the browser cannot draw a frame because your code has not returned.

JavaScript runs on one thread with one call stack. When the stack empties, the loop drains the entire microtask queue — every promise callback, including ones queued while draining — then takes exactly one macrotask, then lets the browser render. That asymmetry is why a promise callback runs before a setTimeout queued earlier, and why an endless chain of promises freezes the page while an endless chain of timeouts does not.

When the stack empties, the event loop takes over, and it treats the two queues differently. It drains the microtask queue completely — every promise callback, and any further microtasks those callbacks queue while it is draining. Then it takes exactly one macrotask: one timer callback, one click handler, one network response. Then the browser is allowed to render, and the turn starts again.

The practical consequence is about long work rather than about trivia. await does not move anything to another thread — it suspends your function and lets the loop continue, but when the awaited value arrives, the continuation runs on the same single thread. A loop that spends four hundred milliseconds calculating blocks the page for four hundred milliseconds whether or not it is written with async. Genuine parallelism needs a Web Worker, which is a separate thread with no access to the DOM.

Values, references, equality

Primitives — numbers, strings, booleans, null, undefined, symbols, bigints — are copied when assigned. Everything else is an object, and objects are handled by reference: two variables can name the same object, and mutating it through one is visible through the other. Arrays and functions are objects, which is why passing an array into a function and sorting it there changes the caller's array.

Copying is shallow by default. Spreading an object copies its top level; nested objects are still shared, so mutating copy.user.name changes the original's too. For a genuine deep copy, structuredClone() is built in and handles cycles, dates, maps and sets — it is what people used to reach for JSON.parse(JSON.stringify(x)) to do, and unlike that trick it does not silently drop functions, undefined values and dates.

On equality, the rule is short: use === and stop thinking about it. Loose equality applies a coercion table that produces defensible-but-surprising results, and no codebase has ever benefited from relying on it. The two exceptions worth knowing are that NaN === NaN is false — use Number.isNaN() — and that x == null is a compact and idiomatic way to test for null or undefined together.

Scope, closures and `this`

A closure is a function that remembers the variables of the scope it was created in, and keeps them alive for as long as the function lives. This is the mechanism behind callbacks that still work later, module privacy, and every hook in React. It is also the mechanism behind the classic loop bug: with var, every iteration shares one binding, so ten callbacks created in a loop all see the final value. With let, each iteration gets its own binding, and the same code does what everyone expected.

That is the practical reason var is no longer used: it is function-scoped and hoisted, so it leaks out of blocks and exists before the line that declares it. let and const are block-scoped, and const should be your default — it prevents rebinding, which is the mistake worth preventing, while still allowing the object it points at to be mutated.

this is decided by how a function is called, not where it is defined — which is why passing obj.method as a callback loses the object. Arrow functions have no this of their own and take it from the surrounding scope, which is the reason they became the default for callbacks. The rule that avoids nearly all of it: use arrow functions for callbacks, and if you find yourself writing .bind(this) or const self = this, you are working around a regular function that should have been an arrow.

Three ways to write the same thing

Asynchronous code has had three generations of syntax, and all three still appear in real projects — often in the same file. They are the same machinery underneath; what differs is how errors travel and how readable the sequence is.

StyleError handlingWhere it still fits
CallbacksBy convention — an error as the first argument, which nothing enforces.Event handlers and older Node APIs; never for new sequential work.
Promises.catch() covers the whole chain above it — one handler for many steps.Composing concurrency: Promise.all, allSettled, race.
async / awaitOrdinary try / catch, and stack traces that name your function.Default for anything sequential — it reads in the order it happens.

The one thing await makes easy to get wrong is accidental sequencing. Awaiting three independent requests one after another takes as long as all three added together; starting all three and awaiting them with Promise.all takes as long as the slowest. If the calls do not depend on each other, awaiting them in a row is a performance bug written in perfectly clean syntax.

Modules

ES modules are the standard: import and export, supported natively by browsers and by Node. Their imports are static — the set of dependencies is known before any code runs — which is what makes tree-shaking possible, because a bundler can prove a named export is never imported and drop it. CommonJS, Node's older require, resolves at runtime, so the same analysis cannot be done reliably.

Two practical notes. Modules are always in strict mode and have their own scope, so nothing leaks to the global object — which removes an entire era of bugs. And prefer named exports to a default export: a default has no fixed name, so different files import the same thing under different names, and renaming it stops being a mechanical refactor.

How this shows up in real delivery

The gap between people who are fast in this language and people who are not is rarely syntax. It is whether they hold the execution model in their head — whether, looking at a piece of code, they can say what is on the stack, what is queued, and when the browser gets to paint. Every performance question on the front end resolves to that, and so does most of what looks like framework magic.

On TypeScript, the honest position: it is not a different language but a type checker over this one, and it erases completely at build time. It catches the class of mistakes this page has been describing — a possibly-undefined value, a misspelled property, a function called with the wrong shape — before they run. It does not change the event loop, it does not make anything faster, and it cannot check data arriving from the network at runtime unless you validate it yourself.

Where it degrades

  • Awaiting independent calls in sequence, which turns a 300 ms page into a 900 ms one with no visible cause.
  • An async callback passed to forEach, which returns immediately and awaits nothing.
  • Promises created and never awaited or caught, which become unhandled rejections.
  • || used for defaults where ?? was meant, so a legitimate 0 or empty string is replaced.
  • Shallow copies treated as deep ones, so a nested mutation reaches the original.
  • Long synchronous work on the main thread, which freezes the page no matter how it is written.
  • var in new code, whose function scope and hoisting produce bugs that let cannot express.

When to use it

Use it when

  • Anything that runs in a browser — there is no alternative, only languages that compile to it.
  • Add TypeScript once a project has more than one contributor or outlives one memory.
  • Reach for a Web Worker when work is genuinely CPU-bound and must not block the interface.
  • Use Promise.all whenever awaited calls do not depend on one another.

Avoid it when

  • Relying on loose equality or truthiness for anything that can legitimately be 0 or empty.
  • Heavy computation on the main thread when the user is expected to keep interacting.
  • Mixing three generations of async syntax in one flow, which makes error paths unreadable.
  • Assuming TypeScript validates runtime data — it does not; parse what crosses the boundary.

Found this useful?

Share it with someone who is working on the same problem.