Unexpected JavaScript Behavior
JavaScript is flexible by design, and that flexibility leads to surprising behavior that has caught every developer off guard at some point. This chapter goes deep into the most common and impactful surprises in JavaScript, explaining not just what happens, but why the language works this way, how it affects real apps, and which defensive habits work best in real, production code.
The goal here is not to scare you or just hand you quick workarounds. The goal is to build real understanding and confidence. Once you understand these patterns properly, you will start spotting bugs before they ever reach production. You will recognize these patterns and catch them right away during code review. You will also start making design choices that naturally avoid these traps, instead of constantly fighting against them.
Type Coercion: The Silent Type Converter
Section titled “Type Coercion: The Silent Type Converter”Type coercion is when JavaScript automatically converts a value from one type into another. It happens in two ways: explicit coercion (where you ask for it yourself) and implicit coercion (where JavaScript decides on its own). Understanding implicit coercion really matters, because it is the cause of many small, sneaky bugs in production.
How JavaScript Coerces Types
Section titled “How JavaScript Coerces Types”When you use the + operator with a string, JavaScript has a built-in rule: if either side is a string, it turns both sides into strings and joins (concatenates) them. When you use -, /, or * instead, JavaScript converts both sides into numbers.
"5" + 1; // "51" (string concatenation)"5" - 1; // 4 (numeric subtraction)true + 1; // 2 (true becomes 1)"10" * "2"; // 20 (both strings to numbers)The operator you choose decides which type conversion path gets used. This one small detail causes a huge number of bugs when developers assume something different is happening.
The Coercion Algorithm in Detail
Section titled “The Coercion Algorithm in Detail”Real-World Bug Scenarios
Section titled “Real-World Bug Scenarios”let userCount = "100";let newUsers = 50;const total = userCount + newUsers;console.log(total); // "10050" - not 150!This bug shows up a lot when data comes in from APIs or form inputs as strings. Your calculations end up silently going wrong, without throwing any error to warn you.
const prices = ["29.99", "19.99"];let total = 0;prices.forEach((price) => { total = total + price; // "019.99" then "019.9919.99"});Another real situation: prices coming from an API arrive as strings. The addition silently joins the text together instead of actually adding the numbers.
The Weakness of Loose Equality
Section titled “The Weakness of Loose Equality”0 == false; // true"" == false; // truenull == undefined; // true"0" == false; // true[] == false; // trueLoose equality (==) converts the types before it compares them. This leads to results that do not feel intuitive at all. Values that feel like they should be different end up matching anyway.
const data = { count: 0 };if (data.count == false) { console.log("Count is false-ish");}If your intention was to check whether count is zero, this happens to work, but only by accident. The code itself is confusing and easy to break later.
Safe Coercion: Explicit Type Conversion
Section titled “Safe Coercion: Explicit Type Conversion”const userCount = "100";const newUsers = 50;const total = Number(userCount) + newUsers; // 150
const input = prompt("Enter age");const age = parseInt(input, 10); // Parse with radix for safetyExplicit conversion is clear and easy to trust. Anyone reading this code can tell exactly what type is expected and what is going to happen.
const value = " 42 ";const clean = Number(value.trim()); // 42Chaining explicit conversion together with cleanup methods like trim() gives you more control and clarity.
Truthy and Falsy: When Not Everything is What You Think
Section titled “Truthy and Falsy: When Not Everything is What You Think”Every value in JavaScript can be used inside a boolean context (like an if statement). JavaScript will not complain if you write if (someNumber), even though someNumber is not actually a boolean. It simply converts the value into a boolean and checks that result. This conversion follows specific rules that often catch developers by surprise.
The Complete List of Falsy Values
Section titled “The Complete List of Falsy Values”There are exactly six falsy values in JavaScript. Everything else counts as truthy.
if (!false) console.log("false is falsy");if (!0) console.log("0 is falsy");if (!-0) console.log("-0 is falsy");if (!0n) console.log("0n (BigInt) is falsy");if (!"") console.log('empty string is falsy');if (!null) console.log("null is falsy");if (!undefined) console.log("undefined is falsy");if (!NaN) console.log("NaN is falsy");All six of these values turn into false when used in a boolean context.
Surprising Truthy Values
Section titled “Surprising Truthy Values”if ([]) console.log("Empty array is truthy");if ({}) console.log("Empty object is truthy");if ("0") console.log('"0" string is truthy');if (new Boolean(false)) console.log("Boolean object is truthy");if (" ") console.log("String with space is truthy");All of these count as truthy. JavaScript treats a plain falsy value differently from an object that simply wraps a falsy value inside it.
Why This Matters in Real Code
Section titled “Why This Matters in Real Code”const results = [];if (results) { console.log("Results found");}This condition is always true, even when the array is completely empty. You need to check .length or use something like .some() instead.
const userSettings = null;if (userSettings) { console.log(userSettings.theme);}If userSettings is null, the block simply does not run, which is the correct behavior here. But beginners often forget to add this check and end up with “cannot read property of null” errors elsewhere.
const count = 0;if (count) { processItems(count);}If count is genuinely zero, this block does not run, and your code skips processing entirely. Use a more explicit check instead, like if (count !== 0) or if (typeof count === 'number').
Defensive Coding Patterns
Section titled “Defensive Coding Patterns”const items = getItems();const hasItems = items && items.length > 0;
const config = fetchConfig();const theme = (config && config.theme) || "light";
const response = await fetch(url);const count = response?.ok ? (await response.json()).count : 0;These patterns make your intentions clear, and they handle falsy values safely.
null and undefined: The Two Empty Values
Section titled “null and undefined: The Two Empty Values”JavaScript has two different ways of representing “empty” or “not present” values. This is a constant source of confusion, because the two behave slightly differently even though they feel similar.
The Historical Context
Section titled “The Historical Context”undefined was meant by the language designers to mean “not yet assigned”. When you declare a variable without giving it a value, it becomes undefined automatically.
null was meant to be something a programmer sets on purpose, to say “I am intentionally setting this to nothing”. You always have to write it yourself.
let x;console.log(x); // undefined
let y = null;console.log(y); // nullThe Type Difference
Section titled “The Type Difference”typeof undefined; // "undefined"typeof null; // "object" (infamous JavaScript bug)The typeof operator returns "object" for null, due to an old implementation quirk from way back that can no longer be fixed, since it would break a huge amount of existing code.
Behavioral Differences
Section titled “Behavioral Differences”undefined == null; // true (loose equality)undefined === null; // false (strict equality)
undefined > 0; // falsenull > 0; // false
undefined + 5; // NaNnull + 5; // 5When you use them in comparisons or math, null and undefined behave differently from each other, and not always in ways you would expect.
Common Scenarios in Real Code
Section titled “Common Scenarios in Real Code”function getUserProfile(userId) { const user = database.findById(userId); return user; // Could be an object or undefined}
const profile = getUserProfile(1);if (profile === undefined) { console.log("User not found");}Database queries often return undefined when there is no matching result.
const config = { timeout: null, // explicitly set to null retries: undefined // never assigned};
if (config.timeout === null) { console.log("Timeout was explicitly disabled");}In configuration objects, null often has a clear meaning, like “this was explicitly turned off”. undefined more often means “this was never configured at all”.
Defensive Strategies
Section titled “Defensive Strategies”const value = data.field ?? "default";The nullish coalescing operator (??) only falls back to a default when the value is null or undefined. Other falsy values, like 0 or "", are left alone and pass through as they are.
const email = user?.profile?.email ?? "unknown";Optional chaining (?.) safely reads nested properties, and simply returns undefined if any step along the way turns out to be null or undefined.
NaN: The Self-Unequal Value
Section titled “NaN: The Self-Unequal Value”NaN stands for “Not a Number”, but oddly enough, typeof NaN actually returns "number". NaN is a special numeric value that shows up when a math operation produces a result that does not make sense. The strangest thing about NaN is that it is not even equal to itself.
Why NaN Exists and How It Appears
Section titled “Why NaN Exists and How It Appears”Number("hello"); // NaN0 / 0; // NaNMath.sqrt(-1); // NaNparseInt("abc", 10); // NaNparseFloat("3.14a"); // NaNAny math operation that cannot produce a real, valid number returns NaN instead of throwing an error. This is an intentional design choice, so the program can fail gracefully instead of crashing.
The Self-Inequality Property
Section titled “The Self-Inequality Property”NaN === NaN; // false (the only value with this property)NaN == NaN; // false
const result = Number("invalid");if (result === NaN) { // This block NEVER runs console.log("Result is NaN");}Since NaN is never equal to itself, you cannot rely on normal equality checks to detect it.
Correct Detection
Section titled “Correct Detection”const value = Number("invalid");
Number.isNaN(value); // true (correct way)isNaN(value); // Avoid this (does coercion)
Object.is(value, NaN); // true (another way)Number.isNaN() is the right method to use here. The older, global isNaN() function converts its argument first, which can lead to confusing results.
isNaN("hello"); // true (coerces to number, result is NaN)isNaN(undefined); // true (coerces undefined to NaN)
Number.isNaN("hello"); // false (does not coerce)Number.isNaN(undefined); // falseProduction Impact
Section titled “Production Impact”const userScores = [];const average = userScores.reduce((sum, score) => sum + score, 0) / userScores.length;
if (average === NaN) { console.log("No scores to average"); // This does not work!}
if (Number.isNaN(average)) { console.log("No scores to average"); // This works correctly}Dividing by the length of an empty array produces NaN. If you do not check for it correctly, the bug can silently slip through.
Floating Point Precision: The Invisible Rounding Error
Section titled “Floating Point Precision: The Invisible Rounding Error”JavaScript uses something called IEEE 754 double-precision floating point math. This standard simply cannot represent every decimal number perfectly in binary. The result is that math with decimal numbers ends up creating tiny rounding errors that stick around through your calculations.
Why Binary Cannot Store Decimals Perfectly
Section titled “Why Binary Cannot Store Decimals Perfectly”Decimal numbers like 0.1, 0.2, and 0.3 cannot be stored exactly in binary floating point. They turn into repeating patterns that have to be rounded off somewhere.
0.1 + 0.2; // 0.300000000000000040.1 + 0.2 === 0.3; // false1.1 + 2.2; // 3.300000000000000310.01 + 20.02; // 30.029999999999998Every calculation that involves decimal numbers builds up tiny little errors.
Why This Matters in Production
Section titled “Why This Matters in Production”const prices = [9.99, 19.99, 5.50];const subtotal = prices.reduce((sum, p) => sum + p, 0);console.log(subtotal); // 35.48000000000001console.log(subtotal.toFixed(2)); // "35.48"E-commerce apps absolutely have to handle this carefully. A rounding error in a subtotal calculation creates differences that users notice almost immediately.
if (calculatedPrice === expectedPrice) { processPayment();}This equality check can fail because of a rounding error, which then ends up blocking a perfectly valid transaction.
The Complete Precision Challenge
Section titled “The Complete Precision Challenge”Defensive Strategies
Section titled “Defensive Strategies”For display purposes:
const total = 0.1 + 0.2;console.log(total.toFixed(2)); // "0.30"console.log(Math.round(total * 100) / 100); // 0.3For comparison operations:
const epsilon = 0.0001;const a = 0.1 + 0.2;const b = 0.3;
if (Math.abs(a - b) < epsilon) { console.log("Values are effectively equal");}For money and financial calculations:
const total = 35.48;const cents = Math.round(total * 100); // 3548 centsconst rounded = cents / 100; // 35.48
const subtotal = 1050; // store as centsconst tax = 105; // 10% tax in centsconst grandTotal = (subtotal + tax) / 100; // 11.55Store currency as whole numbers (cents, pence, paisa) and do all your math using those whole numbers, then only convert it into a display format right at the very end.
this Context: The Dynamic Reference
Section titled “this Context: The Dynamic Reference”The value of this inside a function depends entirely on how the function is actually called, not on where it was written in your code. This is one of the most confusing parts of JavaScript, because your instinct usually tells you that this should be fixed based on where the function was defined.
The Four Calling Patterns
Section titled “The Four Calling Patterns”There are four different ways to call a function in JavaScript, and each one sets this differently.
Method Call: The Obvious Case
Section titled “Method Call: The Obvious Case”const user = { name: "Asha", greet() { return `Hello, ${this.name}`; }};
user.greet(); // "Hello, Asha"// this = userWhen you call a method through the object it belongs to, this becomes that object.
Direct Call: The Problematic Case
Section titled “Direct Call: The Problematic Case”const user = { name: "Ravi", greet() { return `Hello, ${this.name}`; }};
const fn = user.greet;fn(); // "Hello, undefined" or error// this = undefined (strict) or window (non-strict)As soon as you store the method inside a plain variable, calling it loses the original context. this is no longer the user object anymore.
Real-World Bug: Event Handlers
Section titled “Real-World Bug: Event Handlers”class UserManager { constructor(name) { this.name = name; }
displayProfile() { console.log(this.name); }}
const manager = new UserManager("Priya");manager.displayProfile(); // "Priya"
const btn = document.querySelector("button");btn.addEventListener("click", manager.displayProfile);// this = button element, log is undefinedWhen you pass a method as an event handler like this, the browser calls it with this set to the actual button element, not your object.
Real-World Bug: Array Methods
Section titled “Real-World Bug: Array Methods”const user = { titles: ["Engineer", "Manager"], displayTitles() { this.titles.forEach(function(title) { console.log(this.name + ": " + title); // this = undefined, error }); }};
user.displayTitles();Inside this regular callback function, this has already changed to something else.
The Three Solutions
Section titled “The Three Solutions”const user = { name: "Sam", greet() { return `Hello, ${this.name}`; }};
const greetSam = user.greet.bind(user);greetSam(); // "Hello, Sam"bind() creates a brand new function where this is permanently locked in.
const user = { titles: ["Engineer"], displayTitles() { this.titles.forEach((title) => { console.log(this.name + ": " + title); }); }};Arrow functions borrow this from the surrounding code, instead of changing it based on how they are called.
user.greet.call({ name: "Asha" }); // "Hello, Asha"user.greet.apply({ name: "Meera" }); // "Hello, Meera"call and apply run the function right away, using a specific this value that you provide.
var in Loops: The Shared Variable Bug
Section titled “var in Loops: The Shared Variable Bug”The var keyword is scoped to the whole function, not to a block. This means a var variable declared inside a loop actually still exists outside of that loop, and every single run of the loop shares that same variable. This causes one of JavaScript’s most classic and most annoying bugs.
The Classic Bug in Detail
Section titled “The Classic Bug in Detail”for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0);}
// Logs: 3, 3, 3All three callbacks here end up sharing the exact same i variable. By the time the callbacks actually run, i has already become 3.
Why This Happens: Variable Hoisting
Section titled “Why This Happens: Variable Hoisting”The code above behaves the same as this:
var i;for (i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0);}
// i is now 3, all callbacks capture the same variableThe var declaration gets hoisted (moved) outside the loop. There is really only one single i shared across every loop run.
The Expected Behavior with let
Section titled “The Expected Behavior with let”for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0);}
// Logs: 0, 1, 2let is scoped to the block instead. So each loop run gets its own fresh copy of i. Each callback then captures its own separate variable.
The Real Problem in Production Code
Section titled “The Real Problem in Production Code”const callbacks = [];for (var i = 0; i < 5; i++) { callbacks.push(() => processItem(i));}
callbacks.forEach(cb => cb());// All callbacks call processItem(5)You build a list of callbacks inside a loop, expecting each one to use a different value. Instead, they all end up using the exact same shared variable.
Why var Still Matters
Section titled “Why var Still Matters”if (condition) { var result = calculateValue();}
console.log(result); // result exists here even if condition is falseCode that uses var can accidentally leak variables out into the wider scope. This makes behavior harder to predict and easier to get wrong.
Defensive Strategy
Section titled “Defensive Strategy”const callbacks = [];for (const i of [0, 1, 2, 3, 4]) { callbacks.push(() => processItem(i));}
callbacks.forEach(cb => cb());// Logs: 0, 1, 2, 3, 4Use let or const instead, always. In modern code, there is almost never a real reason to use var anymore.
Mutation Through Shared References: The Silent State Catastrophe
Section titled “Mutation Through Shared References: The Silent State Catastrophe”Objects and arrays are reference types in JavaScript. When you assign one to a variable, you are storing a reference (a pointer) to that object, not an actual copy of it. This creates a risky situation, since changing the object through any one of its references ends up affecting every other reference too.
The Simple Bad Case
Section titled “The Simple Bad Case”const user = { name: "Asha", role: "user" };const admin = user;
admin.role = "admin";
console.log(user.role); // "admin" - changed!user and admin both point to the exact same object. So changing one of them ends up changing the other one too.
The Insidious Bug: State Corruption
Section titled “The Insidious Bug: State Corruption”const getDefaultSettings = () => { return { theme: "light", fontSize: 16, notifications: true };};
const userSettings = getDefaultSettings();const adminSettings = getDefaultSettings();
userSettings.theme = "dark";adminSettings.fontSize = 20;
const display = getDefaultSettings();console.log(display); // { theme: "light", fontSize: 16, notifications: true }This case actually works fine, because each call returns a fresh new object. But the bug shows up the moment you accidentally end up sharing one object instead:
let defaultSettings = { theme: "light", fontSize: 16 };
function getUserSettings(id) { const settings = defaultSettings; // Shares reference! if (userPreferences[id]) { settings.theme = userPreferences[id].theme; } return settings;}
const userA = getUserSettings(1);userA.theme = "dark";
const userB = getUserSettings(2);console.log(userB.theme); // "dark" - affected by userA!Nested Reference Sharing
Section titled “Nested Reference Sharing”const original = { name: "Asha", profile: { city: "Delhi", age: 28 }};
const copy = { ...original };
copy.name = "Ravi"; // Changes only copycopy.profile.city = "Mumbai"; // Changes both!
console.log(original.profile.city); // "Mumbai" - corruptedThe spread operator only makes a shallow copy. The top-level properties become independent, but anything nested inside, like the profile object, is still shared between both.
Real-World Scenarios: State and Collections
Section titled “Real-World Scenarios: State and Collections”const shoppingCart = [];
function addProduct(id, name, price) { const item = { id, name, price }; shoppingCart.push(item);}
const item = shoppingCart[0];item.price = 0; // Free item!
// The original cart is corruptedIf any part of your code can grab an item from the cart array and change it directly, the whole cart can end up in an unreliable state.
const dataCache = {};
function fetchAndCache(url) { if (!dataCache[url]) { dataCache[url] = fetch(url).then(r => r.json()); } return dataCache[url];}
const data1 = await fetchAndCache("/api/users");data1.users[0].role = "hacker";
const data2 = await fetchAndCache("/api/users");// data2 is the same object, corruptedCaching values without protecting them from outside changes can quietly corrupt your data.
Defensive Patterns: Immutable Updates
Section titled “Defensive Patterns: Immutable Updates”const user = { name: "Asha", role: "user" };const admin = { ...user, role: "admin" };
console.log(user.role); // "user" - unchangedconsole.log(admin.role); // "admin"The spread operator creates a brand new object, with whichever properties you choose either kept or overridden.
const profile = { name: "Ravi", address: { city: "Bangalore", zip: "560001" }};
const updated = { ...profile, address: { ...profile.address, city: "Delhi" }};
console.log(profile.address.city); // "Bangalore"console.log(updated.address.city); // "Delhi"For updating nested data like this, you need to spread out each level separately.
const copy = structuredClone(original);// Deep copy with no shared referencesstructuredClone() makes a full, deep copy for cases where writing out the spread pattern by hand would get too messy.
Arrow Functions in Callbacks: The Return Surprise
Section titled “Arrow Functions in Callbacks: The Return Surprise”Arrow functions can be written in two different forms. The short form (=> expression) automatically returns whatever the expression evaluates to. The block form (=> { statement }) needs you to write return yourself. Mixing these two forms up causes quiet, silent bugs in your logic.
The Two Forms
Section titled “The Two Forms”const doubled = [1, 2, 3].map((x) => x * 2);// [2, 4, 6]The result of the expression is returned automatically here.
const doubled = [1, 2, 3].map((x) => { return x * 2;});// [2, 4, 6]Here, you must write return yourself.
The Silent Failure
Section titled “The Silent Failure”[1, 2, 3].map((x) => { x * 2;});// [undefined, undefined, undefined]The block form expects an explicit return. Without one, the function quietly returns undefined instead.
Why This Matters in Real Code
Section titled “Why This Matters in Real Code”const users = [ { id: 1, name: "Asha", active: true }, { id: 2, name: "Ravi", active: false }];
const activeUsers = users.filter((user) => { console.log("Checking", user.name); user.active;});
// Logs: Checking Asha, Checking Raviconsole.log(activeUsers); // All users, not filteredThe filter does not actually work here, because there is no explicit return written.
Another Common Mistake
Section titled “Another Common Mistake”const createHandler = (message) => { { setTimeout(() => console.log(message), 1000); }};
// The curly braces are a block, not an object!Beginners sometimes forget that writing => followed by {} creates a function body, not an object.
Correct Patterns
Section titled “Correct Patterns”For single expressions:
const doubled = nums.map((x) => x * 2);const evens = nums.filter((x) => x % 2 === 0);const users = people.map((p) => ({ id: p.id, name: p.name }));For multiple statements:
const processed = data.map((item) => { const normalized = item.trim().toLowerCase(); const validated = normalized.length > 0; return validated ? normalized : null;});Event Loop and Timing: The Async Execution Model
Section titled “Event Loop and Timing: The Async Execution Model”JavaScript execution happens in layers. Understanding the event loop and the task queue is the key to understanding why async code behaves the way it does, even when it feels surprising.
The Complete Execution Timeline
Section titled “The Complete Execution Timeline”Once your synchronous code finishes running, the event loop first works through the microtask queue (promises), then through the macrotask queue (setTimeout), and only after that does it render the page.
Sync Code First
Section titled “Sync Code First”console.log("A");setTimeout(() => console.log("C"), 0);console.log("B");
// Logs: A, B, CThe synchronous console.log calls run first. The setTimeout callback is delayed, even though the timeout is set to 0ms.
Microtasks Before Macrotasks
Section titled “Microtasks Before Macrotasks”console.log("A");
Promise.resolve().then(() => console.log("B"));setTimeout(() => console.log("C"), 0);
console.log("D");
// Logs: A, D, B, CPromise callbacks (microtasks) always run before timeout callbacks (macrotasks).
Multiple Microtasks and Macrotasks
Section titled “Multiple Microtasks and Macrotasks”console.log("1");
Promise.resolve() .then(() => { console.log("2"); return Promise.resolve(); }) .then(() => console.log("3"));
setTimeout(() => { console.log("4"); Promise.resolve().then(() => console.log("5"));}, 0);
console.log("6");
// Logs: 1, 6, 2, 3, 4, 5All of the microtasks (2 and 3) finish before any macrotask (4) gets a turn. Then, inside macrotask 4, a brand new microtask (5) gets queued, and it runs before the next macrotask does.
Why Rendering Waits
Section titled “Why Rendering Waits”const elem = document.querySelector("#box");
elem.style.background = "red";setTimeout(() => { elem.style.background = "blue";}, 0);
// You see one flash of red, not one of eachThe browser will not render anything until all microtasks have finished. Both of these style changes happen before the page actually gets rendered, so all you ever see is the final blue state.
Practical Implications for Real Apps
Section titled “Practical Implications for Real Apps”async function loadUser(id) { const user = await fetch(`/api/users/${id}`); const data = await user.json(); return data;}
const users = [];for (let i = 0; i < 100; i++) { loadUser(i).then((u) => users.push(u));}
console.log(users); // Empty! Runs before any promises resolvePromises are asynchronous. The code right after the loop runs immediately, before any of the promises have actually finished resolving.
Error Handling in Async Code: The Scoped Limitation
Section titled “Error Handling in Async Code: The Scoped Limitation”try/catch blocks can only catch errors that are thrown within the same call stack. Errors thrown inside callbacks or promise chains need to be handled differently.
The Try/Catch Blindness
Section titled “The Try/Catch Blindness”try { setTimeout(() => { throw new Error("Delayed error"); }, 0);} catch (e) { console.error("Caught:", e); // This does not run}
// Unhandled error!The try/catch block already finishes running before the callback even gets a chance to run. The callback runs later, in a future call stack, so the error is never caught here.
Promise Rejection Handling
Section titled “Promise Rejection Handling”fetch("/api/user") .then((response) => response.json()) .then((data) => processUser(data)) .catch((error) => { console.error("Request failed:", error.message); });Every promise chain should have a .catch() handler attached to it.
try { const response = await fetch("/api/user"); const data = await response.json(); processUser(data);} catch (error) { console.error("Request failed:", error.message);}Async/await lets your error handling look just like normal, synchronous code.
The Response.ok Check
Section titled “The Response.ok Check”try { const response = await fetch("/api/user/999"); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); return data;} catch (error) { console.error("Failed:", error.message);}fetch does not automatically fail (reject) on HTTP errors. You have to check response.ok yourself, and throw an error manually if needed.
Unhandled Promise Rejection Risk
Section titled “Unhandled Promise Rejection Risk”const getUser = async (id) => { const response = await fetch(`/api/users/${id}`); return response.json();};
getUser(1);// No .catch() handler - error unhandled
getUser(2).catch((e) => console.error(e));// Properly handledForgetting to add a .catch() to a promise leaves any errors completely unhandled.
Complete Error Handling Pattern
Section titled “Complete Error Handling Pattern”async function safeRequest(url, options = {}) { try { const response = await fetch(url, options);
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); }
const contentType = response.headers.get("content-type"); if (!contentType?.includes("application/json")) { throw new Error("Expected JSON response"); }
const data = await response.json(); return { ok: true, data };
} catch (error) { console.error(`Request to ${url} failed:`, error.message); return { ok: false, error }; }}
// Usageconst result = await safeRequest("/api/users");if (result.ok) { processUsers(result.data);} else { showError(result.error);}This kind of solid wrapper function handles every error case and always gives back a result in a consistent shape.
Why These Behaviors Exist
Section titled “Why These Behaviors Exist”JavaScript was designed in just 10 days, back in 1995, with one main goal: make scripting on web pages easy and fast. Many of these surprising behaviors are really just historical compromises, or side effects of how flexible and dynamic JavaScript was built to be.
Type coercion exists because JavaScript wanted to stay flexible about types. The way this binding works exists because JavaScript borrowed some object ideas from a language called Self. The event loop exists because browsers needed a way to do non-blocking async tasks, long before async/await was even invented.
Understanding the “why” behind all of this makes these behaviors feel a lot less random, and a lot more predictable.
Production Safety Checklist
Section titled “Production Safety Checklist”Use this checklist to catch these kinds of surprises before they ever reach production:
- Replace every
==with===during code reviews. - Watch out for
varinside loops with async callbacks. Useletinstead. - Check types explicitly before doing operations:
if (typeof value === 'number'). - Check the API response status:
if (!response.ok) throw new Error(). - Use
Number.isNaN()instead of relying on truthy or falsy checks to detect NaN. - Test floating point math with comparisons that allow for a small margin of error (epsilon).
- Track and remove any event listeners that get left behind, inside your cleanup functions.
- Use immutable patterns for shared state, like spread or
structuredClone(). - Add
.catch()handlers to every promise. - Test with empty states: empty arrays, null values, and zero counts.
- Review how
thisbehaves inside callbacks, arrow functions, and event handlers. - Prefer explicit type conversion over relying on implicit coercion.