Skip to content

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 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.

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.

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 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.

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

Loose 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.

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 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()); // 42

Chaining 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.

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.

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.

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').

graph TD A[Any Value] --> B{Apply Boolean Conversion} B -->|false 0 -0 0n | C[Empty String 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 your intentions clear, and they handle falsy values safely.

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.

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); // null
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.

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

When you use them in comparisons or math, null and undefined behave differently from each other, and not always in ways you would expect.

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”.

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 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.

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

Any 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.

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.

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

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.30000000000000004
0.1 + 0.2 === 0.3; // false
1.1 + 2.2; // 3.3000000000000003
10.01 + 20.02; // 30.029999999999998

Every calculation that involves decimal numbers builds up tiny little 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 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.

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 grandTotal = (subtotal + tax) / 100; // 11.55

Store 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.

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.

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 through the object it belongs to, this becomes 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)

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.

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 like this, the browser calls it with this set to the actual button element, not your object.

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.

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.

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.

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

All three callbacks here end up sharing the exact same i variable. By the time the callbacks actually run, i has already become 3.

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 variable

The var declaration gets hoisted (moved) outside the loop. There is really only one single i shared across every loop run.

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

let 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.

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.

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

Code that uses var can accidentally leak variables out into the wider scope. This makes behavior harder to predict and easier to get wrong.

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 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.

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.

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!
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 only makes a shallow copy. The top-level properties become independent, but anything nested inside, like the profile object, is still shared between both.

graph TD A[original] --> B[name] A --> C[profile object ref] D[copy] --> E[name copy] D --> F[profile object ref same] C -.-> G[Shared profile object] F -.-> 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 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, corrupted

Caching values without protecting them from outside changes can quietly corrupt your data.

const user = { name: "Asha", role: "user" };
const admin = { ...user, role: "admin" };
console.log(user.role); // "user" - unchanged
console.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 references

structuredClone() 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.

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

The result of the expression is returned automatically here.

[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.

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 actually work here, because there is no explicit return written.

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.

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.

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

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.

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

The synchronous console.log calls run first. The setTimeout callback is delayed, even though the timeout is set to 0ms.

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) always 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 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.

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 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.

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 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.

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.

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/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.

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 to add a .catch() to a promise leaves any errors completely 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);
}

This kind of solid wrapper function handles every error case and always gives back a result in a consistent shape.

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.

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” behind all of this makes these behaviors feel a lot less random, and a lot more predictable.

Use this checklist to catch these kinds of surprises before they ever reach production:

  1. Replace every == with === during code reviews.
  2. Watch out for var inside loops with async callbacks. Use let instead.
  3. Check types explicitly before doing operations: if (typeof value === 'number').
  4. Check the API response status: if (!response.ok) throw new Error().
  5. Use Number.isNaN() instead of relying on truthy or falsy checks to detect NaN.
  6. Test floating point math with comparisons that allow for a small margin of error (epsilon).
  7. Track and remove any event listeners that get left behind, inside your cleanup functions.
  8. Use immutable patterns for shared state, like spread or structuredClone().
  9. Add .catch() handlers to every promise.
  10. Test with empty states: empty arrays, null values, and zero counts.
  11. Review how this behaves inside callbacks, arrow functions, and event handlers.
  12. Prefer explicit type conversion over relying on implicit coercion.