JavaScript Runs One Thing at a Time, and Everything Confusing Follows From That

Omni Academy · omniacad.com

Some of it genuinely is historical baggage. But the parts that confuse people most are not traps at all — they follow from one design decision, made because JavaScript was built to run inside a browser where blocking is unacceptable. Understand that decision and most of the strangeness becomes predictable.

One thread, and what it costs

JavaScript executes on a single thread. There is one call stack, and only one thing happens at a time. That is a serious constraint. A browser has to stay responsive — if your code is running, nothing else can: no clicks, no scrolling, no rendering. A loop that takes three seconds freezes the tab for three seconds. So the language had to solve a problem: how do you wait for something slow — a network request, a timer, a file — without blocking? The answer is that JavaScript never waits. It hands slow work to the environment (the browser or Node), registers a callback, and carries straight on. When the work finishes, the callback is queued, and it runs when the stack is empty. That mechanism is the event loop, and it explains almost every "why did this run in the wrong order" question. console.log('first'); setTimeout(() => console.log('third'), 0); Promise.resolve().then(() => console.log('second')); console.log('actually first and a half'); The output is first, actually first and a half, second, third. Even with a zero-millisecond timer. The synchronous lines run to completion first, then microtasks (promise callbacks), then macrotasks (timers). The zero in setTimeout is not "now", it is "as soon as possible after everything already queued". Once you hold that picture — stack empties, microtasks drain, then one macrotask, then repeat — asynchronous JavaScript stops being mysterious.

Promises, and then async/await

A promise is an object representing a value that is not here yet. It is in one of three states: pending, fulfilled, or rejected. Once it settles, it never changes again. The old way was callbacks, and the problem was not ugliness but error handling. Every callback had to handle its own errors, and nesting them produced code where a single failure path had to be written five times. fetch('/api/user') .then(res => res.json()) .then(user => fetch(/api/orders/${user.id})) .then(res => res.json()) .catch(err => console.error(err)); // one place for all four failures async/await is syntax over exactly this. An async function always returns a promise, and await pauses that function — not the thread — until the promise settles. async function loadOrders() { try { const user = await (await fetch('/api/user')).json(); const orders = await (await fetch(/api/orders/${user.id})).json(); return orders; } catch (err) { console.error(err); } } That reads top to bottom and uses ordinary try/catch. The one thing worth internalising: await does not block the program. While this function is suspended, the thread is free and other work runs. Which leads to the most common async mistake — awaiting in a loop when the operations are independent: // Three seconds: each waits for the last. for (const id of ids) { results.push(await fetchUser(id)); }

// One second: all three in flight at once. const results = await Promise.all(ids.map(fetchUser)); Promise.all rejects as soon as any one rejects. Promise.allSettled waits for all and reports each outcome, which is usually what you want when partial success is acceptable.

Equality, and why === is not pedantry

== performs type coercion before comparing. The rules are genuinely intricate, and the results include 0 == '0' being true, 0 == [] being true, and '0' == [] being false — three statements that cannot all be reconciled with any intuitive notion of equality. === compares without coercion. Same type and same value, or false. The rule is simple: use === always. The one accepted exception is x == null, which is true for both null and undefined and is a convenient way to check "nothing here". Related: the falsy values are false, 0, -0, 0n, '', null, undefined and NaN. Everything else is truthy — including '0', 'false', [] and {}. An empty array being truthy catches people constantly. This is also why ?? exists alongside ||. || falls back on any falsy value, so a legitimate 0 gets replaced: const pageSize = input || 20; // 0 becomes 20. Probably a bug. const pageSize = input ?? 20; // only null/undefined become 20.

this and the rule that actually governs it

this is not the object the function was defined in. In a regular function, this is decided by how the function is called, not where it was written. const user = { name: 'Ama', greet() { return Hi, ${this.name}; } };

user.greet(); // "Hi, Ama" - called as a method const f = user.greet; f(); // "Hi, undefined" - called bare Nothing about the function changed. Only the call site did. Arrow functions do not have their own this at all; they take it from the surrounding scope at the time they were written. That is precisely why they fixed the classic callback bug: class Timer { constructor() { this.count = 0; } start() { setInterval(() => { this.count++; }, 1000); // arrow: this is the Timer } } With a regular function there, this would be decided by how setInterval calls it — not by the class. The practical rule: methods on objects should be regular functions; callbacks inside them should be arrows.

Scope, hoisting, and closures

var is function-scoped and hoisted: declared throughout the function, initialised where you wrote it. let and const are block-scoped and sit in a "temporal dead zone" until their declaration — accessing them early throws rather than quietly giving undefined. Use const by default, let when you must reassign, and var never. const prevents reassignment, not mutation. const arr = [] then arr.push(1) is fine; arr = [] is not. A closure is a function that remembers the variables where it was created, even after that outer function has returned. function counter() { let n = 0; return () => ++n; } const next = counter(); next(); next(); // 2 n is not garbage collected, because the returned function still refers to it. This is the mechanism behind private state, memoisation, and every useState in React. It is also behind the classic loop bug — with var all iterations shared one binding; with let each iteration gets its own, which is why the modern version simply works.

Objects and arrays are handled by reference

Primitives are copied by value. Objects and arrays are copied by reference. const a = { n: 1 }; const b = a; b.n = 2; a.n; // 2 - same object This is the source of a large share of real bugs: a function "shouldn't have" changed its argument, two components share state they appear not to share, a cached value mutates underneath you. Shallow copies are easy — { ...obj }, [...arr], Object.assign({}, obj) — but they only copy one level. Nested objects are still shared. For a genuine deep copy, modern environments have structuredClone(obj); the old JSON.parse(JSON.stringify(obj)) trick works but silently destroys Dates, Maps, Sets, undefined and functions.

The array methods worth knowing cold

Most day-to-day JavaScript is transforming arrays, and knowing six methods removes nearly all manual loops. items.map(f) // same length, each transformed items.filter(f) // subset, same elements items.reduce(f, x) // collapse to a single value items.find(f) // first match, or undefined items.some(f) // does any match? items.every(f) // do all match? map, filter, slice and concat return new arrays. push, splice, sort and reverse mutate in place — and sort in particular surprises people twice: it mutates, and by default it sorts as strings, so [10, 9, 1].sort() gives [1, 10, 9]. Pass a comparator: .sort((a, b) => a - b).

Modules, and the two systems

ES modules are the standard: import and export, static, analysable by tools, and what the browser understands natively with <script type="module">. Node's older CommonJS (require, module.exports) is still everywhere. They differ in more than syntax — ES module imports are hoisted and live, CommonJS requires are executed in place and copied — which is why mixing them produces confusing errors. New code should use ES modules. Modules also give you something that used to need an IIFE: a real private scope. A variable in a module is not global unless you export it.

Errors, and the async gap

try/catch catches synchronous throws, and awaited rejections inside an async function. It does not catch a rejection from a promise you never awaited: try { fetchUser(); // no await - the rejection escapes this block } catch (e) { /* never runs */ } An unhandled rejection is one of the most common ways a production bug stays invisible. Either await it, or attach a .catch(). Also worth knowing: fetch only rejects on a network failure. A 404 or a 500 resolves successfully with response.ok === false. Code that only has a .catch() will treat a server error as a success.

Destructuring and spread, which are everywhere in modern code

Destructuring pulls values out of objects and arrays by shape, and once you read it fluently a great deal of modern JavaScript becomes legible. const { name, email = 'none', address: { city } = {} } = user; const [first, second, ...rest] = items; Three things are happening in that first line, and each earns its place. The property is extracted by name. A default is supplied if the property is undefined — note undefined, not falsy, so an explicit null does not get replaced. And a nested property is reached in the same expression, with its own default so that a missing address does not throw. Destructuring in parameter lists is what makes options objects pleasant: function createUser({ name, role = 'student', active = true }) { } createUser({ name: 'Ama', role: 'admin' }); The caller names its arguments, so the call site is self-documenting and the order no longer matters — a genuine improvement over four positional parameters where the third and fourth are both booleans. The spread operator does the reverse. It expands an iterable into its parts, and it is the idiomatic way to copy and merge without mutating: const updated = { ...user, role: 'admin' }; // new object, role overridden const combined = [...listA, ...listB]; Later properties win, which makes { ...defaults, ...overrides } a very common and very readable pattern. In a parameter list the same three dots mean the opposite — gather the remaining arguments into an array — and which one it is depends entirely on which side of the = it appears.

Optional chaining, and failing quietly on purpose

Reaching into data you did not create used to mean defensive ladders: const city = user && user.address && user.address.city; Optional chaining says the same thing once: const city = user?.address?.city; // undefined if any link is missing const n = response?.items?.length ?? 0; user.onChange?.(); // only called if it exists It short-circuits to undefined the moment it meets null or undefined, rather than throwing "cannot read property of undefined" — which is probably the single most-seen error message in the language. A caution worth stating, because it is easy to over-apply: optional chaining suppresses an error, and sometimes the error was the useful signal. If a field is genuinely required and missing, failing loudly at the point of the bug is better than carrying undefined through three more functions and failing somewhere unrelated. Use it where absence is legitimate, not to quieten everything.

The DOM, and why events bubble

In the browser, JavaScript's main job is the document. Two pieces are worth getting right early. Selecting and changing. document.querySelector takes a CSS selector and returns the first match; querySelectorAll returns all of them as a static NodeList. To put text on the page use textContent, not innerHTML: textContent treats the value as text, while innerHTML parses it as markup, so any user-supplied string becomes a cross-site scripting hole. That one habit prevents an entire vulnerability class. Events bubble. A click on a button fires on the button, then its parent, then that parent's parent, all the way up. This is not a quirk; it is what makes delegation possible: list.addEventListener('click', e => { const item = e.target.closest('li'); if (item) select(item); }); One listener on the list handles every item, including items added later. The alternative — a listener per row — costs memory, has to be rebound whenever the list changes, and is the usual reason dynamically added elements "do not respond to clicks". Two more worth knowing: e.preventDefault() stops the browser's default action, such as a form submitting or a link navigating, and e.stopPropagation() stops the bubbling. They are different, and reaching for the second when you meant the first breaks other listeners that had every right to run.

What to practise

  1. Predict the output of a snippet mixing console.log, setTimeout(…, 0) and a resolved promise, then run it. Repeat until you are never surprised. That single exercise teaches the event loop better than any diagram.
  2. Rewrite a callback chain as promises, then as async/await, and make the error handling correct in all three.
  3. Take a loop with an await inside it and convert it to Promise.all. Time both.
  4. Write a function that accidentally mutates its argument, watch the bug, then fix it with a copy. You will recognise the shape forever. JavaScript is not a collection of exceptions to be memorised. It is a single-threaded language that refuses to block, with a type system that tries too hard to be helpful and a this bound at the call site. Those three facts explain most of what looks arbitrary — and once they are in place, the language becomes unusually pleasant to work in.

script noscript async defer type src href rel integrity crossorigin referrerpolicy fetchpriority