JavaScript
JavaScript is the programming language every web browser runs: one thread per page, one call stack, and a queue for everything that has to wait. Most of what looks arbitrary about it follows from that model, or from a decision made in its first years that the web can no longer take back. The page opens with twelve surprises, the rule behind each and the fix, then covers the event loop, where async code loses time and errors, numbers and references, scope, modules and the dependency tree, and five documented cases.
- Threads
- One per page, plus workers
- Standard
- ECMAScript 2026 · yearly
- Largest exact integer
- 2^53 − 1
Twelve surprises and the rule behind each
The table comes first, because it is why most people open a page about JavaScript: the language just did something that looks like a bug. Each row is what you see, the rule that produces it, and the fix. None of the twelve is a bug. Each one is a rule written down in the specification, meeting code written by someone who had not met it yet — and there are few enough rules that the rest of this page covers every one of them.
| What you see | What is actually happening | The fix |
|---|---|---|
1. A promise callback runs before a setTimeout(fn, 0) queued earlier | Promise callbacks go to the microtask queue, which is emptied completely before the next timer is even looked at. | Never use a timer to order work. Await the thing you depend on. |
| 2. The page freezes while a calculation runs | There is one thread. While your function is on the stack, nothing else runs — no click handler and no paint. | Split the work and yield between chunks, or move it to a Web Worker. |
3. Three awaits in a row take as long as all three added up | Each await finishes before the next request is even started. | Start the independent ones together and await Promise.all. |
4. forEach with an async callback does not wait | forEach ignores what the callback returns — here, a promise — and moves on at once. | for…of with await inside, or await Promise.all(items.map(…)). |
5. A try / catch does not catch the error | The error was thrown later, on a different turn of the loop, after the try block had already finished. | Await inside the try, or attach .catch() where you hand the promise off. |
6. 0.1 + 0.2 is 0.30000000000000004 | Every number is a 64-bit binary floating-point value, and 0.1 has no exact binary form. | Count money in whole minor units (cents, kopiykas); round only for display. |
| 7. An ID from the server comes back with different last digits | Integers above 2^53 − 1 cannot be stored exactly, and JSON.parse rounds them without a word. | Send large IDs as strings. Use BigInt if you must do arithmetic on them. |
| 8. Changing the copy changes the original | Spread and Object.assign copy one level. Nested objects are still shared by both. | structuredClone() for a deep copy, or copy every level you change. |
9. A valid 0 or '' gets replaced by the default | || treats every falsy value as missing — 0, the empty string and false included. | ?? for defaults and ?. for optional access: both mean “null or undefined” and nothing else. |
| 10. Callbacks created in a loop all see the last value | With var the whole function has one binding, and every callback closes over that same one. | let or const: each loop iteration gets its own binding. |
11. [10, 9, 1].sort() returns [1, 10, 9] | With no comparator, sort converts the values to strings and compares them character by character. | (a, b) => a - b for numbers, Intl.Collator for words, toSorted() to leave the original alone. |
| 12. A date shows up one day early | new Date('2026-09-30') is midnight UTC — which is still 29 September in New York. | Keep a calendar date as a date, not an instant: Temporal.PlainDate, or the string itself. |
One thread, two queues
JavaScript in a browser tab runs on one thread. There is one call stack — the list of functions currently running, each waiting for the one above it to return — and while anything 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 UIUser Interface — what the user seesEverything the user sees and operates: screens, buttons, fields, states. UI is how the interaction looks; whether the interaction makes sense in the first place is a UX question, and the two get confused constantly.All terms” literally means: the browser cannot draw a frame because your code has not returned yet. It is row 2 of the table.
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 may render, and the turn starts again. That asymmetry is all of row 1. The promise callback was not faster than the timer; it was in a queue that is always emptied first.
| Where code waits | What goes there | When it runs | The trap |
|---|---|---|---|
| Call stack | Ordinary synchronous code | Now, to the end | Nothing paints until it returns (row 2) |
| Microtask queue | Promise callbacks, the code after every await, queueMicrotask | All of them, as soon as the stack is empty | A chain that keeps queueing more starves the page for good |
| Task queue (macrotasks) | Timers, user events, network responses, postMessage | One per turn of the loop | Nested timers are clamped to at least 4 ms, so 0 is a request, not a promise |
| Animation frame | requestAnimationFrame callbacks | Just before the next paint | Heavy work here delays the very frame it was meant to draw |
| Web Worker | A separate script with its own loop | In parallel, on another thread | No DOM, and data crosses only as copied messages |
Row 2 has a number attached to it. The browser treats any task that holds the thread for more than 50 milliseconds as a long task, and since March 2024 Google has measured responsiveness as INP — Interaction to Next Paint, one of the three Core Web VitalsGoogle’s page-experience metricsThree measurements Google takes from real visitors’ browsers: LCP, how fast the main content appears; INP, how fast the page responds to input; and CLS, how much the layout jumps. They feed into search ranking, but only as one signal among many — a fast page with nothing worth reading still loses to a slow one that answers the question.All terms — where 200 milliseconds from a click to the next frame counts as good. A loop that spends 400 milliseconds sorting a list fails that on its own, whether or not it is written with async. await does not move anything to another thread: it suspends your function and lets the loop continue, but when the awaited value arrives, the code after it runs on the same single thread.
Where async code loses time and errors
Asynchronous code has had three generations of syntax, and all three still appear in real projects — often in the same file. Underneath they are the same machinery: a promise is an object standing for a result that is not ready yet, and async and await are a way of writing promise code so that it reads top to bottom. What differs between the three is how errors travel and how readable the order of events is.
| Style | Error handling | Where it still fits |
|---|---|---|
| Callbacks | By 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. | Combining work that runs at the same time: Promise.all and its siblings. |
| async / await | Ordinary try / catch, and stack traces that name your function. | The default for anything sequential — it reads in the order it happens. |
The clean syntax makes one mistake easy, and it is row 3. Three independent requests awaited one after another take as long as all three added together, because the second one does not start until the first has finished. Started together and awaited with Promise.all, they take as long as the slowest. Nothing in the code looks wrong: every line is correct, and the page is still more than twice as slow as it needs to be.
The third row of the diagram is row 4 of the table, and it is worse than slow. forEach calls your async callback three times, receives three promises, throws them away and returns at once. The line after the loop runs before a single result has arrived — and if one of the three requests fails, no code anywhere is waiting to hear about it. for…of with await inside gives you the one-at-a-time version on purpose; await Promise.all(items.map(…)) gives you the all-at-once one.
| Combinator | Resolves when | Use it for |
|---|---|---|
| Promise.all | All succeed — or rejects at the first failure | Several things the next step needs, every one of them |
| Promise.allSettled | All have finished, success or failure | A dashboard of widgets where one failing must not blank the rest |
| Promise.race | The first one settles, either way | A timeout: the request against a timer |
| Promise.any | The first one succeeds | The same file from several mirrors, whichever answers |
Two habits close most of this section. First, a promise you create is a promise you await, return or catch — a linter rule that flags floating promises (typescript-eslint calls it no-floating-promises) finds every place you forgot, in a minute. Second, anything the user can abandon should be cancellable: pass an AbortController signal to fetch, and abort it when the component goes away or the search box changes. The React page shows what happens without it — an older, slower response arriving second and overwriting the newer one.
Numbers, references and missing values
JavaScript has one kind of number for almost everything, and it is a 64-bit binary floating-point value — the IEEE 754 double. It stores about 15 to 17 significant decimal digits, and it stores fractions in base two. That is the whole of row 6: 0.1 in binary is a repeating fraction, like one third in decimal, so it is stored rounded, and two rounded values added together show the rounding. Every language that uses doubles gives the same answer; JavaScript is merely the one where most people meet it first.
Row 7 is the same fact at the other end of the scale. A double holds whole numbers exactly only up to 2^53 − 1, which is 9,007,199,254,740,991 and is available as Number.MAX_SAFE_INTEGER. Past it, the gap between neighbouring representable numbers becomes 2, then 4, and odd numbers start to vanish: JSON.parse('{"id": 9007199254740993}').id returns 9007199254740992, with no error and no warning. Databases hand out 64-bit IDs as a matter of routine, so this is not a theoretical limit. Twitter hit it in 2010, and the cases section below has the story.
What a variable actually holds
Primitives — numbers, strings, booleans, null, undefined, symbols and bigints — are copied when assigned. Everything else is an object, and a variable does not contain an object: it contains a reference to one, an address. Two variables can therefore name the same object, and changing it through one is visible through the other. Arrays and functions are objects too, which is why sorting an array inside a function changes the caller’s array.
The diagram is row 8. Spreading an object — { ...original } — creates a new object and copies its top level, and one of the things at the top level is a reference to the nested address. So the copy and the original now share it, and a change to copy.user.city shows up in original.user.city. structuredClone(), built into every major browser since 2022, copies every level and handles dates, maps, sets and circular references. It replaced the old JSON.parse(JSON.stringify(x)) trick, which silently dropped functions and undefined and turned every date into a string.
References are also why frameworks compare state by identity. React decides whether state changed by asking whether the new value is the same reference as the old one, so pushing into an array in place hands it the same address and the screen does not update. The fix is the same shape as row 8 in reverse: build a new object for every level you change, and leave the rest shared on purpose.
Missing, empty and falsy
The language has two values for “nothing” — undefined, which is what an unset variable or a missing property gives you, and null, which somebody wrote on purpose — and a short list of values that count as false in a condition: false, 0, the empty string, NaN, null and undefined, plus the rarer -0 and 0n. Row 9 lives in the gap between those two lists. A count of zero and an empty name are real answers, and || throws them away.
| You write | Treats as missing | Right for |
|---|---|---|
| x || fallback | Every falsy value, 0 and '' included | Almost nothing — this is row 9 |
| x ?? fallback | null and undefined only | Defaults for settings and parameters |
| x?.y | null and undefined only — and stops there | Reading into data that may be absent |
| x == null | null and undefined only | The one place loose equality is idiomatic |
On equality the rule is short: use === and stop thinking about it. Loose equality applies a conversion table whose results are defensible one at a time and surprising together, and no codebase has ever been improved by relying on it. Two exceptions are worth knowing. NaN === NaN is false, because not-a-number is not equal to anything, itself included, so test with Number.isNaN(). And typeof null is 'object' — a bug from 1995 that stayed: a later proposal to fix it was turned down, because too much code already depended on it.
Scope, closures and the built-ins that cannot change
A closure is a function that remembers the variables of the place it was created and keeps them alive for as long as the function itself lives. It is the mechanism behind callbacks that still work later, behind private state in a module, and behind every hook in React. It is also row 10. With var, a variable belongs to the whole function, so ten callbacks created in a loop all close over the same single variable and all see its final value. With let, each iteration of the loop gets a fresh variable, and the same code does what everybody expected.
That is the practical reason var is gone from new code. It is scoped to the function rather than the block and is hoisted, so it leaks out of if and for blocks and exists, as undefined, before the line that declares it. let and const are scoped to the block, and reading them before their declaration is an error rather than a silent undefined. const should be the default: it forbids reassigning the name, which is the mistake worth preventing, while still letting you change the object the name points to — row 8 again.
this is decided by how a function is called, not where it was written, so passing obj.method as a callback loses obj and this becomes undefined. Arrow functions have no this of their own and take it from the code around them, which is why they became the default for callbacks. If you find yourself writing .bind(this) or const self = this, you are working around a regular function that should have been an arrow.
Two built-ins frozen in their first year
Rows 11 and 12 are not about the execution model at all. They are behaviour the language shipped early and cannot change, because the web’s first rule is that a browser update must not break a working site. sort() with no argument compares values as strings, so 10 sorts before 9 exactly as “10” sorts before “9” in a dictionary. Date parses a bare date such as '2026-09-30' as midnight UTCCoordinated Universal Time — the world’s reference timeThe time standard that clocks and logs are normally kept in, with no time zones and no daylight saving. It inserts leap seconds to stay close to the Earth’s rotation, which is why GPS time, which does not, now runs 18 seconds ahead of it.All terms but a date with a time and no offset as local time, and then prints both in the local time zone — so a date picked on a laptop in Kyiv shows correctly there and one day early for a colleague in New York.
Both have modern replacements that sit beside the old behaviour rather than changing it. toSorted() (2023) returns a sorted copy instead of reordering the array in place. Temporal, which became part of the language standard in 2026 and ships in Chrome and Firefox — Safari is still catching up, so a polyfill covers it there — separates the things Date mixes up: Temporal.PlainDate is a calendar date with no time zone, Temporal.Instant is a moment on the timeline, and Temporal.ZonedDateTime is a moment somewhere in particular. A birthday is a PlainDate. A payment timestamp is an Instant.
Modules, the build and the dependency tree
A module is a file with its own scope that states what it takes from other files and what it offers them. JavaScript has two module systems in daily use, and a project that mixes them hits errors that make no sense until you know there are two.
| ES modules | CommonJS | |
|---|---|---|
| Syntax | import / export | require() / module.exports |
| Dependencies known | Before any code runs — the imports are static | Only while it runs — require is a function call |
| Unused exports | Can be proved unused and dropped from the bundle | Usually shipped anyway |
| Where it is the default | Browsers, and every new package | Older Node.js code and packages |
Between the code you write and the code a browser runs there is usually a build: a bundler that joins hundreds of modules into a few files, a compiler that turns TypeScript and newer syntax into what the target browsers understand, and a minifier that shortens every name. Two consequences follow. An error in production points at line 1, column 48,213 of a file you never wrote, so a build that does not produce source maps — the files that map the output back to your code — has thrown away your stack tracethe calls in progress at an errorThe list of function calls that were in progress when an error was raised, innermost first, printed with the error. It shows where a failure surfaced, which is not always where it was caused — and a process killed from outside, by the kernel or an orchestrator, leaves none at all.All terms. And TypeScript is gone by the time anything runs: it checks the shapes you declared, and it cannot check a response from the network unless you validate that response yourself.
Then there is what you did not write at all. npm install with three packages in package.json routinely brings in hundreds, because each of your three has its own dependencies, and theirs have theirs. The median web page now ships 558 KB of JavaScript to a phone, and 44% of it is not used during the page load (Web Almanac, 2024). Every one of those packages is maintained by somebody you have never met, who can hand it over or remove it on any given afternoon.
The diagram shows why that is a structural risk rather than a matter of choosing good packages. The package that causes trouble is rarely one you picked. It is a leaf three levels down, tiny, with a single maintainer, reached through several of your dependencies at once — which is exactly the shape of left-pad in 2016 and event-stream in 2018, both in the cases below. The controls that work are all about that tree, not about your three direct choices:
- A lockfileexact versions of every dependencyA file the package manager writes next to the list of dependencies, recording the exact version of every package installed — including the ones your dependencies pulled in. Committed to the repository, it makes every install identical; without it, two builds of the same commit can run different code.All terms committed to the repository —
package-lock.jsonor its equivalent — which pins the exact version of every node in the tree, not only the three you named. npm cirather thannpm installin the pipeline, so a build installs exactly what the lockfile says or fails.- Updates that arrive as reviewed pull requesta change proposed for reviewA request to merge one branch into another, where the change is reviewed, discussed and checked by the pipeline before it reaches the main branch; GitLab calls the same thing a merge request. Its cost is waiting: a pull request that sits unreviewed for days is where long-lived branches and painful merges come from.Full article →All terms, with a few days’ delay after a release — hijacked versions are often found and pulled within hours or days, and the delay keeps you out of that window.
- A dependency added only after asking what it would cost to write the thing yourself; an eleven-line function is cheaper to own than to depend on.
- Third-party scripts served from your own origin, or pinned with a Subresource Integrity hash, rather than loaded live from a domain somebody else controls.
Five cases, and what each one was missing
Each case below is documented in public by the company, the registry or the researchers involved. In none of them was the missing piece clever. It was a string instead of a number, a pinned version, a review, or a script served from your own server.
| Case | What happened | What was missing |
|---|---|---|
| Twitter IDs, 2010 | New tweet IDs were about to pass 53 bits, which JavaScript cannot hold exactly | IDs sent as strings — added as id_str (row 7) |
| left-pad, 2016 | An eleven-line package was unpublished and builds of Babel and Atom failed for about two and a half hours | A copy of what you depend on, and a registry that cannot delete it under you |
| SmooshGate, 2018 | A new standard method broke sites running an old library, so the standard renamed its method | Nothing on the sites’ side — which is why the language keeps its old mistakes |
| event-stream, 2018 | A new maintainer added a dependency that stole from one bitcoin wallet’s users | Reviewed updates to the tree, not just to your own code |
| polyfill.io, 2024 | The domain behind a script on 100,000+ sites was sold, and the script began redirecting phone users | The script served from your own server |
left-pad is the dependency tree at its smallest. On 22 March 2016, after a naming dispute with the company Kik, a developer unpublished all 273 of his packages from npm. One of them, left-pad, was eleven lines that padded a string to a length. It sat under line-numbers, which asked for exactly version 0.0.3, and line-numbers sat under Babel and Atom — so builds across the ecosystem started failing within minutes. Someone republished a left-pad in ten minutes, but as version 1.0.0, which did not satisfy “exactly 0.0.3”. npm restored the original at 4:55 PM Pacific time, about two and a half hours in, and then changed its rules so that a package others depend on can no longer simply be unpublished.
The last two cases are the same attack on two different layers. event-stream was a stream library with about 1.5 million downloads a week and roughly 1,600 packages depending on it. In 2018 its author, who no longer used it, accepted an offer of help and gave publishing rights to a stranger. The stranger added a dependency, flatmap-stream, whose payload stayed dormant unless it found itself inside the build of the Copay bitcoin wallet, and there it tried to steal the keys of wallets holding large balances. It was found in November, after millions of installs. polyfill.io was a script tag on more than 100,000 sites, loaded live from somebody else’s domain. In February 2024 the domain and the project’s GitHub account were sold, and by June the script was redirecting phone users to betting sites. Its original author had already told everyone to remove it, since modern browsers no longer needed it.
Where it degrades
JavaScript rarely fails in a way that stops the page. It fails by being slower than it should be, by losing an error somewhere nobody looks, or by shipping a stranger’s code. These are the shapes that takes.
- Independent calls awaited in sequence, which turns a 400 ms page into a 900 ms one with no visible cause.
- An
asynccallback passed toforEach, which returns at once and awaits nothing. - Promises created and never awaited or caught, which in Node.js end the process.
||used for defaults where??was meant, so a legitimate0or empty string is replaced.- Money held in floating-point numbers, with
toFixedhiding the error until the totals are reconciled. - Shallow copies treated as deep ones, so a nested change reaches the original.
- Long synchronous work on the main thread, which freezes the page however it is written.
- Listeners that are added on every screen and never removed, so the tab grows until it is closed.
- A dependency added to save ten lines, and a live script tag from a domain somebody else controls.
- TypeScript treated as validation, so data from the network is trusted because its type was declared.
There is also an honest limit, and it is not about the language. The language is finite. Everything in the table at the top can be learned in a week, and a linter catches most of the rest. What cannot be learned is a dependency tree of several hundred packages written by people you will never meet, and no tool reads it for you — an audit tells you about vulnerabilities somebody has already reported, not about the maintainer who hands the package to a stranger next month. The controls in the modules section reduce the risk. Only fewer dependencies reduce the exposure.
The other half of the limit is the device. Nothing in the language tells you how your code feels on a mid-range Android phone over a weak connection, which is where a large share of your users actually are. A page that responds in 80 ms on a developer’s laptop can take several times that on the phone. Open the Performance panel with CPUCentral Processing Unit — the processorThe processor that executes a program’s instructions. Calling a task “CPU-bound” means it is limited by computation rather than by waiting on disk or network — and the two need opposite fixes, so the distinction is worth making early.All terms throttling on, or better, hold the real phone and tap through the one task the page exists for. It is the only test on this page that measures what the user feels rather than what the code says.
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 person’s memory.
- Reach for a Web Worker when work is genuinely CPU-bound and must not block the interface.
- Use
Promise.allwhenever awaited calls do not depend on one another.
Avoid it when
- Relying on loose equality or truthiness for anything that can legitimately be
0or empty. - Floating-point numbers for money — count whole minor units instead.
- Heavy computation on the main thread when the user is expected to keep interacting.
- Assuming TypeScript validates runtime data — it does not; parse what crosses the boundary.
Sources and method
What this page stands on
- 12 sources
- 3 documented failures
- Checked October 13, 2026
Practices and where the terms come from
the rules behind the twelve surprises, the event loop and the timers- StandardECMAScript Language Specification (ECMA-262) (opens in a new tab)Ecma TC39
- StandardHTML Standard — Web application APIs: event loops and timers (opens in a new tab)WHATWG
- DocumentationMDN — JavaScript (opens in a new tab)MDN Web Docs
- ArticleInteraction to Next Paint becomes a Core Web Vital on March 12 (opens in a new tab)web.dev · 2024
- ResearchThe Web Almanac 2024 — JavaScript (opens in a new tab)HTTP Archive · 2024
The failures this page learns from
left-pad, event-stream and polyfill.io in “Five cases”- Postmortemkik, left-pad, and npm (opens in a new tab)npm Blog · Mar 23, 2016
- PostmortemDetails about the event-stream incident (opens in a new tab)npm Blog · Nov 26, 2018
- PostmortemA post-mortem of the malicious event-stream backdoor (opens in a new tab)Snyk · 2018
- ArticlePolyfill supply chain attack hits 100K+ sites (opens in a new tab)Sansec · Jun 25, 2024
The other two cases
Twitter’s 64-bit IDs and SmooshGateResearched with Claude. Finding and cross-checking the primary sources was done with Claude (Anthropic). Selection, writing and fact-checking: the Anatomy of IT Product editors.
Found this useful?
Share it with someone who is working on the same problem.