Skip to content

Unexpected JavaScript Behavior

JavaScript is flexible by design, and that flexibility creates surprising behavior that has caught every developer off guard at some point. This chapter goes deep into the most common and impactful unexpected behaviors, explaining not just what happens, but why the language works this way, how it affects real applications, and what defensive strategies work best in production.

The goal is not to create fear or teach workarounds. The goal is to build deep truth and confidence. Once you understand these patterns thoroughly, you will see bugs coming before they reach production. You will recognize the patterns and catch them immediately during code review. You will make architectural decisions that naturally avoid these pitfalls rather than constantly fighting against them.


Type coercion is JavaScript’s automatic conversion of values from one type to another. It happens in two forms: explicit coercion (you request it) and implicit coercion (JavaScript decides). Understanding implicit coercion is critical because it is the source of many subtle production bugs.

When you use the + operator with a string, JavaScript has a built-in decision: if either operand is a string, convert both to strings and concatenate. When you use -, /, or *, JavaScript converts both sides to 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 use determines the type conversion path. This single decision point causes countless bugs when developers assume the behavior.

graph TD A[Binary Operation] --> B{Which Operator?} B -->|+ operator| C[Check if Either is String] B -->|Other Operators| D[Convert Both to Number] C -->|String Found| E[Convert All to String] C -->|No String| F[Convert Both to Number] E --> G[Concatenate] D --> H[Perform Math] F --> H
let userCount = "100";
let newUsers = 50;
const total = userCount + newUsers;
console.log(total); // "10050" - not 150!

This bug happens frequently when data comes from APIs or form inputs as strings. Your calculations silently fail.

const prices = ["29.99", "19.99"];
const total = 0;
prices.forEach((price) => {
total = total + price; // "019.99" then "019.9919.99"
});

Another real scenario: prices from an API arrive as strings. The addition silently concatenates instead of summing.

0 == false; // true
"" == false; // true
null == undefined; // true
"0" == false; // true
[] == false; // true

Loose equality (==) performs type coercion before comparison. This creates unintuitive results. Different types that intuitively feel unequal will match.

const data = { count: 0 };
if (data.count == false) {
console.log("Count is false-ish");
}

If you meant to check if count is zero, this works accidentally. But the code is confusing and fragile.

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 safety

Explicit conversion is clear and trustworthy. Anyone reading this code knows exactly what type is expected and what happens.

const value = " 42 ";
const clean = Number(value.trim()); // 42

Chaining explicit conversions with cleaning methods gives you 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 in a boolean context. JavaScript does not complain if you write if (someNumber) even though someNumber is not a boolean. It converts the value to a boolean and checks the result. This conversion follows specific rules that catch developers off-guard.

There are exactly six falsy values in JavaScript. Everything else is 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 convert to false in boolean context.

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");

These are all truthy. The language distinguishes between a primitive falsy value and an object that wraps a falsy value.

const results = [];
if (results) {
console.log("Results found");
}

This condition is always true, even when the array is empty. You must check .length or use .some().

const userSettings = null;
if (userSettings) {
console.log(userSettings.theme);
}

If settings is null, the block does not run. This is correct behavior. But beginners often forget this check and get “cannot read property of null” errors.

const count = 0;
if (count) {
processItems(count);
}

If count is legitimately zero, the block does not run. Your code skips processing. Use explicit checks instead: if (count !== 0) or if (typeof count === 'number').

graph TD A[Any Value] --> B{Apply Boolean Conversion} B -->|false 0 -0 0n | C["" null undefined NaN] C -->|false|D[Don't Enter if Block] B -->|Everything Else|E[true] E -->|true|F[Enter if Block]
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 intent explicit and handle falsy values safely.


JavaScript has two different ways to represent “empty” or “not present” values. This dualism is a source of endless confusion because they behave slightly differently but feel the same.

undefined was intended by the language designers to mean “not yet assigned”. When you declare a variable without initializing it, it is undefined.

null was intended as a programmer-provided “I explicitly set this to nothing” value. It must be explicitly written by you.

let x;
console.log(x); // undefined
let y = null;
console.log(y); // null
typeof undefined; // "undefined"
typeof null; // "object" (infamous JavaScript bug)

JavaScript’s typeof operator returns “object” for null due to a historical implementation quirk that cannot be fixed without breaking billions of lines of code.

undefined == null; // true (loose equality)
undefined === null; // false (strict equality)
undefined > 0; // false
null > 0; // false
undefined + 5; // NaN
null + 5; // 5

When used in comparisons or arithmetic, they behave unexpectedly and differently.

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 no match is found.

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 semantic meaning: “explicitly turned off”. undefined means “not yet configured”.

const value = data.field ?? "default";

The nullish coalescing operator (??) provides a fallback only for null or undefined. Other falsy values like 0 or "" pass through.

const email = user?.profile?.email ?? "unknown";

Optional chaining (?.) safely accesses nested properties and returns undefined if any step is null or undefined.


NaN stands for “Not a Number”, but ironically, typeof NaN returns "number". NaN is a special numeric value that appears when mathematical operations produce invalid results. The most unusual property of NaN is that it is not equal to itself.

Number("hello"); // NaN
0 / 0; // NaN
Math.sqrt(-1); // NaN
parseInt("abc", 10); // NaN
parseFloat("3.14a"); // NaN

Any mathematical operation that cannot produce a valid number returns NaN instead of throwing an error. This is a design choice to allow graceful fallback.

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");
}

Because NaN is not equal to itself, you cannot use normal equality checks to detect it.

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 correct method. The global isNaN() function coerces its argument first, which can lead to surprises.

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); // false
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
}

An empty array divided by zero produces NaN. Silent logic failures happen if you do not check correctly.


Floating Point Precision: The Invisible Rounding Error

Section titled “Floating Point Precision: The Invisible Rounding Error”

JavaScript uses IEEE 754 double-precision floating point arithmetic. This standard cannot perfectly represent all decimal numbers in binary. The result is that arithmetic with decimals produces rounding errors that persist 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 represented exactly in binary floating point. They become repeating patterns that must be rounded.

0.1 + 0.2; // 0.30000000000000004
0.1 + 0.2 === 0.3; // false
1.1 + 2.2; // 3.3000000000000003
10.01 + 20.02; // 30.029999999999998

Every calculation with decimal numbers accumulates tiny errors.

const prices = [9.99, 19.99, 5.50];
const subtotal = prices.reduce((sum, p) => sum + p, 0);
console.log(subtotal); // 35.48000000000001
console.log(subtotal.toFixed(2)); // "35.48"

E-commerce applications absolutely must handle this. A rounding error in subtotal calculation creates discrepancies that users notice immediately.

if (calculatedPrice === expectedPrice) {
processPayment();
}

This equality check may fail due to rounding error, blocking legitimate transactions.

graph TD A[Decimal Input] --> B[Convert to Binary Representation] B --> C[Rounding Loss Occurs] C --> D[Store Approximation] D --> E[Arithmetic Operations] E --> F[Errors Accumulate] F --> G[Display Mismatch]

For display purposes:

const total = 0.1 + 0.2;
console.log(total.toFixed(2)); // "0.30"
console.log(Math.round(total * 100) / 100); // 0.3

For 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 cents
const rounded = cents / 100; // 35.48
const subtotal = 1050; // store as cents
const tax = 105; // 10% tax in cents
const total = (subtotal + tax) / 100; // 11.55

Store currency as integers (cents, pence, paisa) and do all math with integers, then convert to display format only at the end.


The value of this inside a function depends entirely on how the function is called, not where it is defined. This is one of the most confusing aspects of JavaScript because your intuition says this should be statically determined by the location in the code where the function was written.

There are four different ways to call a function in JavaScript, and each one sets this differently.

graph TD A[Function Call] --> B{How Was It Called?} B -->|Direct Call| C[this = undefined strict mode window otherwise] B -->|Method Call| D[this = object that owns method] B -->|Constructor new| E[this = new empty object] B -->|call apply bind| F[this = specified argument]
const user = {
name: "Asha",
greet() {
return `Hello, ${this.name}`;
}
};
user.greet(); // "Hello, Asha"
// this = user

When you call a method by accessing it through the object, this is that object.

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)

Once you store the method in a variable, calling it loses the context. this is no longer the user object.

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 undefined

When you pass a method as an event handler, the method is called by the browser with this as the element.

const user = {
titles: ["Engineer", "Manager"],
displayTitles() {
this.titles.forEach(function(title) {
console.log(this.name + ": " + title);
// this = undefined, error
});
}
};
user.displayTitles();

Inside the callback function, this has changed.

const user = {
name: "Sam",
greet() {
return `Hello, ${this.name}`;
}
};
const greetSam = user.greet.bind(user);
greetSam(); // "Hello, Sam"

bind() creates a new function with this permanently set.


The var keyword is function-scoped, not block-scoped. This means a var variable declared inside a loop exists outside the loop, and all iterations of the loop share the same variable. This is the cause of one of JavaScript’s most classic and frustrating bugs.

for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// Logs: 3, 3, 3

All three callbacks share the same i variable. By the time the callbacks run, i is 3.

The above code is equivalent to:

var i;
for (i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// i is now 3, all callbacks capture the same variable

The var declaration is hoisted outside the loop. There is only one i for all iterations.

for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// Logs: 0, 1, 2

let is block-scoped. Each iteration gets its own i variable. Each callback captures a different variable.

const callbacks = [];
for (var i = 0; i < 5; i++) {
callbacks.push(() => processItem(i));
}
callbacks.forEach(cb => cb());
// All callbacks call processItem(5)

You create callbacks in a loop, intending each to capture a different value. Instead, all capture the same variable.

if (condition) {
var result = calculateValue();
}
console.log(result); // result exists here even if condition is false

Code using var can accidentally expose variables to outer scopes. This is unpredictable and error-prone.

const callbacks = [];
for (const i of [0, 1, 2, 3, 4]) {
callbacks.push(() => processItem(i));
}
callbacks.forEach(cb => cb());
// Logs: 0, 1, 2, 3, 4

Use let or const. Always. There is almost no reason to use var in modern code.


Mutation Through Shared References: The Silent State Catastrophe

Section titled “Mutation Through Shared References: The Silent State Catastrophe”

Objects and arrays are reference types. When you assign them to a variable, you are storing a reference to the object, not a copy of the object. This creates a dangerous situation where changes to the object through any reference affect all other references.

const user = { name: "Asha", role: "user" };
const admin = user;
admin.role = "admin";
console.log(user.role); // "admin" - changed!

Both user and admin point to the same object. Changing one changes the other.

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 seems fine because each variable gets its own object. But bugs happen when you inadvertently share:

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!
const original = {
name: "Asha",
profile: { city: "Delhi", age: 28 }
};
const copy = { ...original };
copy.name = "Ravi"; // Changes only copy
copy.profile.city = "Mumbai"; // Changes both!
console.log(original.profile.city); // "Mumbai" - corrupted

The spread operator makes a shallow copy. Top-level properties are independent, but nested objects are still shared.

graph TD A[original] --> B[name] A --> C[profile object ref] D[copy] --> E[name copy] D --> F[profile object ref same] C -.->|same reference| G[Shared profile object] F -.->|same reference| G

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 corrupted

If any code can access items in your cart array and mutate them, the cart state becomes unreliable.

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, corrupted

Caching without protecting references leads to corruption.

const user = { name: "Asha", role: "user" };
const admin = { ...user, role: "admin" };
console.log(user.role); // "user" - unchanged
console.log(admin.role); // "admin"

Spread operator creates a new object with selected or overridden properties.

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 nested updates, spread each level.

const copy = structuredClone(original);
// Deep copy with no shared references

structuredClone() creates a complete deep copy for cases where immutable patterns are too verbose.


Arrow Functions in Callbacks: The Return Surprise

Section titled “Arrow Functions in Callbacks: The Return Surprise”

Arrow functions have two syntaxes. The concise form (=> expression) automatically returns the expression. The block form (=> { statement }) requires explicit return. Mixing these up causes silent logic failures.

const doubled = [1, 2, 3].map((x) => x * 2);
// [2, 4, 6]

The result of the expression is automatically returned.

[1, 2, 3].map((x) => {
x * 2;
});
// [undefined, undefined, undefined]

Block form expects an explicit return. Without it, the function returns undefined.

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 Ravi
console.log(activeUsers); // All users, not filtered

The filter does not work because there is no explicit return.

const createHandler = (message) => {
{
setTimeout(() => console.log(message), 1000);
}
};
// The curly braces are a block, not an object!

Beginning developers sometimes forget that => with {} creates a function body, not an object literal.

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 has layers. Understanding the event loop and task queue is fundamental to understanding why async code behaves surprisingly.

graph TD A[JavaScript Starts] --> B[Call Stack Executes Sync Code] B --> C{Call Stack Empty?} C -->|No| B C -->|Yes| D[Process Microtask Queue] D --> E[Process Macrotask Queue] E --> F[Render if Needed] F --> D

When synchronous code finishes, the event loop processes microtasks (promises), then macrotasks (setTimeout), then renders.

console.log("A");
setTimeout(() => console.log("C"), 0);
console.log("B");
// Logs: A, B, C

Synchronous console.log calls run first. The setTimeout callback is delayed even with 0ms timeout.

console.log("A");
Promise.resolve().then(() => console.log("B"));
setTimeout(() => console.log("C"), 0);
console.log("D");
// Logs: A, D, B, C

Promise callbacks (microtasks) run before timeout callbacks (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, 5

All microtasks (2, 3) complete before any macrotask (4). Inside macrotask 4, a new microtask (5) is queued and runs before the next macrotask.

const elem = document.querySelector("#box");
elem.style.background = "red";
setTimeout(() => {
elem.style.background = "blue";
}, 0);
// You see one flash of red, not one of each

The browser does not render until all microtasks complete. Both style changes happen before rendering, so you see the final blue state.

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 resolve

Promises are async. Code after the loop runs in the same turn, before any promise resolves.


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 thrown in the same call stack. Errors thrown in callbacks or promise chains require different handling strategies.

try {
setTimeout(() => {
throw new Error("Delayed error");
}, 0);
} catch (e) {
console.error("Caught:", e);
// This does not run
}
// Unhandled error!

The try/catch ends execution before the callback runs. The callback runs in a future call stack and the error is not caught.

fetch("/api/user")
.then((response) => response.json())
.then((data) => processUser(data))
.catch((error) => {
console.error("Request failed:", error.message);
});

Every promise chain must have a .catch() handler.

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 reject on HTTP errors. You must check response.ok and throw manually.

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 handled

Forgetting a .catch() on a promise leaves errors unhandled.

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 };
}
}
// Usage
const result = await safeRequest("/api/users");
if (result.ok) {
processUsers(result.data);
} else {
showError(result.error);
}

A robust wrapper handles all error cases and returns a consistent result format.


JavaScript was designed in 10 days in 1995 with a specific set of goals: make web page scripting easy and fast. Many of these unexpected behaviors are historical compromises or side effects of JavaScript’s dynamic nature.

Type coercion exists because JavaScript wanted to be flexible with types. The this binding exists because JavaScript borrowed object model concepts from Self. The event loop exists because browsers needed non-blocking async operations before async/await was invented.

graph LR A[Language Design Goals] --> B[Flexibility] A --> C[Speed] A --> D[Browser Integration] B --> E[Implicit Type Conversion] C --> F[Dynamic this Binding] D --> G[Event Loop] E --> H[Unexpected Coercion Bugs] F --> I[Context Confusion] G --> J[Subtle Timing Issues]

Understanding the “why” makes these behaviors seem less random and more predictable.


Use this checklist to catch unexpected behavior before it reaches production:

  1. Replace all == with === in code reviews.
  2. Check var loops with async callbacks. Use let.
  3. Validate type explicitly before operations: if (typeof value === 'number').
  4. Check API response status: if (!response.ok) throw new Error().
  5. Use Number.isNaN(), not truthy falsy checks for NaN detection.
  6. Test floating point math with assertions: expect equality within epsilon.
  7. Mark and remove leaked event listeners in cleanup functions.
  8. Use immutable patterns for shared state: spread or structuredClone().
  9. Add .catch() handlers to all promises.
  10. Test with empty states: empty arrays, null values, zero counts.
  11. Review this binding in callbacks, arrow functions, and event handlers.
  12. Prefer explicit type conversion over implicit coercion.