Hoisting and the Temporal Dead Zone
Most beginners trip over hoisting and assume variables just magically exist. They don't. let and const are hoisted but sit in a temporal dead zone until the line of declaration runs. Access them before that line and you get a ReferenceError, not undefined. This is different from var, which gets hoisted and initialized to undefined automatically. I spent three weeks debugging a module export that was always undefined. Turned out the import was referencing a const declaration that hadn't executed yet because of a circular dependency. The fix was restructuring the module order so the consumer loaded after the provider. Not elegant, but it worked and the circular dependency never came back.
JavaScript Study Guide Common Mistakes To Avoid
Reference Types and Mutation
Objects and arrays are reference types. When you assign one variable to another, you are copying the reference, not the value. Mutate one and the other changes too. This catches people off guard constantly because primitives behave the opposite way. The shallow copy problem shows up everywhere. Object.assign() and the spread operator only do shallow copies. Nested objects still share references. If your data structure goes more than one level deep, you need a deep clone strategy. I use structuredClone() in modern environments. It handles circular references properly where lodash's cloneDeep sometimes stumbles on DOM nodes or certain built-in types. == does type coercion. === does not. This is covered in every tutorial but people still write if (value == null) when they mean undefined or when they actually want strict comparison. The one exception where == is arguably useful is the null check shortcut. value == null matches both null and undefined. Beyond that, stick to strict equality everywhere.
A subtle issue: NaN === NaN is false. If you need to check for NaN, use Number.isNaN(). The global isNaN() coerces its argument first, which means isNaN("hello") returns true even though "hello" is a string.
Closures and Loop Variables
Before let and const, closures inside loops were a minefield. With var, all iterations share the same variable declaration. By the time an async callback fires, the loop has finished and the variable holds its final value. This is why let was introduced per-iteration binding. It solved the classic problem where every setTimeout in a loop logged the same number. However, closures still leak memory if you attach event listeners inside a closure without cleaning up. I had a React component where each render created a new closure-bound handler and attached it to a canvas element. The old handlers never got garbage collected. The browser tab would eventually crash after about 200 renders. The fix was using a ref to store the handler and calling removeEventListener on cleanup.
Get the Full Details

Promises and Error Handling
Unhandled promise rejections are one of the most common sources of silent failures in JavaScript applications. A rejected promise without a .catch() handler or try/catch in an async function will throw an unhandled rejection. In Node.js this exits the process. In the browser it logs a warning but continues executing. Always rethrow after catching in an async context. Swallowing the error silently makes debugging impossible later. The other mistake people make is using .then() chains without a final catch. Every chain needs a rejection handler at the end, even if it just logs the error. Async functions always return a promise, even if you don't explicitly return anything. Writing await someAsyncFunction() inside a synchronous function does not block the event loop. JavaScript is single-threaded and non-blocking. The await keyword just pauses execution of that async function until the promise resolves, while the rest of the event loop continues processing other work.
A common performance mistake is awaiting promises sequentially when they are independent. If you have three API calls that don't depend on each other, writing them as three separate await statements in a row means each one waits for the previous to complete. Use Promise.all() to run them in parallel instead. This can cut total wait time from the sum of all latencies down to just the slowest one.
const [users, posts, comments] = await Promise.all([
fetchUsers(),
fetchPosts(),
fetchComments(),
]);
The this Keyword
this binding in JavaScript is not what most people expect from other languages. It is determined by how a function is called, not where it is defined. Arrow functions capture this lexically from their surrounding scope. Regular functions bind this at call time based on the invocation context. Passing a regular method as a callback is the classic trap:
class Counter {
constructor() {
this.count = 0;
}
increment() {
this.count++;
console.log(this.count);
}
}
const counter = new Counter();
setTimeout(counter.increment, 1000); // undefined or throws
By the time increment runs, this is no longer the Counter instance. The fix is either an arrow function wrapper, .bind(), or rewriting the method as an arrow function property. I prefer arrow function properties for class methods when the method needs to reference this, since it eliminates the binding problem entirely at the definition site. JavaScript uses prototypal inheritance, not class-based inheritance like Java or C#. The class keyword is syntactic sugar over the prototype chain. Under the hood, new creates an object with a hidden link to the constructor's prototype. Method lookups traverse this chain until they find a match or reach null. The mistake here is assuming that instanceof works reliably across different frames or iframes. Each frame has its own global object and its own Array constructor. An array created in an iframe will fail instanceof Array checks in the parent frame. Use Array.isArray() instead, which works across realms.

Synchronous Blocking in Async Code
JavaScript runs on a single thread. Long-running synchronous operations block the entire event loop. A heavy computation or a large loop that processes thousands of items will freeze the UI and prevent any other code from running. This is why you should break expensive work into chunks using setTimeout, requestIdleCallback, or web workers. I once had a function that parsed a 50MB JSON file on the main thread. The browser became completely unresponsive for about eight seconds. Splitting the parsing into smaller chunks using a web worker reduced the perceived freeze to nearly zero and freed the main thread for user interaction the entire time. The trade-off is that web workers cannot access the DOM, so any results need to be posted back via postMessage.
Type Coercion Edge Cases
JavaScript's type system coerces values in ways that are not always obvious. The expression [] + [] evaluates to an empty string. {} + {} evaluates to NaN in a standalone statement because the first {} is parsed as an empty block, not an object literal. Inside an expression context like ({}) + ({}), it would coerce both objects to [object Object] and concatenate them. The + operator behaves differently depending on operand types. If either operand is a string, it concatenates. Otherwise it adds numerically. 1 + "2" is "12". 1 - "2" is -1 because subtraction does not coerce strings to numbers in the same concatenation sense.
Module System Confusion
CommonJS (require / module.exports) and ES Modules (import / export) coexist in the JavaScript ecosystem and they behave differently. ES Module imports are static and read-only live bindings. Changing the exported value in one file updates the import in another. CommonJS exports are copied by value at require time. Updating the original module after requiring it does not affect the cached copy. Tooling adds another layer of confusion. Build tools like webpack and esbuild can transpile between module systems. Runtime environments differ too. Node.js supports ES modules with .mjs files or when "type": "module" is set in package.json, but the default is still CommonJS. Browsers only support ES modules natively. Mixing them incorrectly causes runtime errors that are hard to trace without understanding which module system each file uses.
Memory Leaks
JavaScript has automatic garbage collection, but leaks still happen when references are held unintentionally. The most common sources are global variables, detached DOM elements, and unclosed timers or event listeners. A timer callback that references a large object keeps that object alive indefinitely, even if nothing else uses it. Set intervals and request animation frames are persistent references. Clear them when they are no longer needed. DOM elements that are removed from the document tree but still referenced in JavaScript variables will not be garbage collected. This is especially problematic in single-page applications where views are swapped out but cleanup code is missing. The Chrome DevTools Memory panel can help identify leaks. Take a heap snapshot, trigger the suspected leak, then take another snapshot and compare them. The diff view shows which objects survived between snapshots. If objects you expect to be garbage collected are still present, trace back their reference chains to find what is holding them.

Arrow Function Limitations
Arrow functions are convenient but they do not have their own this, arguments, super, or new.target. They inherit these from the enclosing scope. This makes them unsuitable as object methods, event handlers where this should refer to the element, or constructors. Trying to use new with an arrow function throws a TypeError immediately. Another limitation is that arrow functions cannot be used with call, apply, or bind to change their this binding. Calling those methods on an arrow function has no effect. The this value remains whatever it was in the enclosing scope at definition time.
Event Propagation
Events in the DOM bubble and capture phases. Clicking a button inside a div triggers handlers on the button first, then the div, then the body, and so on up to the document root. This is bubbling. Capturing is the reverse. Most event listeners are attached in the bubbling phase by default. The mistake is assuming that stopping propagation on a child element also stops handlers on the parent. stopPropagation() prevents the event from reaching other listeners on ancestor elements, but it does not prevent the parent's own listener from running if it is attached to the same element. stopImmediatePropagation() is needed to prevent other listeners on the same element from firing. Event delegation exploits bubbling to handle events on many child elements with a single parent listener. This is efficient for dynamic content where elements are added and removed frequently. The trade-off is that you need to check event.target or event.currentTarget to determine which child was actually interacted with, and some events like focus and blur do not bubble in the standard way.
Strict Mode
Strict mode disables several implicit features that make JavaScript easier to misuse accidentally. It throws errors for silent failures like assigning to a read-only property or using an undeclared variable. It prevents accidental global variable creation by making this undefined in functions called without a receiver. Not every project enables strict mode by default. Older codebases may mix strict and non-strict code in the same file, which can lead to inconsistent behavior. Some third-party libraries do not declare strict mode and rely on non-strict behavior. If you add "use strict" to a file that imports such a library, you might encounter unexpected errors from code that was quietly depending on sloppy mode features.
Optional Chaining and Nullish Coalescing
Optional chaining (?.) and nullish coalescing (??) are relatively new features that reduce boilerplate but introduce subtle behavior differences. Optional chaining returns undefined when any part of the chain is null or undefined. It does not throw an error. This is useful for deeply nested object access. The nullish coalescing operator only falls back when the left operand is null or undefined. It does not treat 0, false, or empty strings as falsy. This is different from the logical OR operator (||), which treats all falsy values as reasons to use the right operand. Using || for default values can produce bugs when the legitimate value is 0 or an empty string. Use ?? instead. JSON.stringify cannot serialize functions, undefined, Symbol values, Map, Set, or circular references. It silently drops undefined values in objects and converts them to null in arrays. Circular references throw a TypeError.

A workaround for circular references is a custom replacer function that tracks seen objects in a WeakMap and replaces circular pointers with a placeholder string. This is a common pattern in logging middleware where you need to stringify request objects that may contain cyclic references.
function safeStringify(value) {
const seen = new WeakMap();
return JSON.stringify(value, (key, val) => {
if (val && typeof val === "object") {
if (seen.has(val)) return "[Circular]";
seen.set(val, true);
}
return val;
});
}
This adds overhead but is acceptable for debugging output. It should not be used in performance-critical code paths. Understanding the event loop is essential for writing correct asynchronous code. JavaScript executes tasks in two queues: macrotasks and microtasks. Promises, queueMicrotask, and MutationObserver callbacks are microtasks. setTimeout, setInterval, and I/O callbacks are macrotasks. Microtasks execute before the next macrotask, even if the macrotask queue is not empty. This ordering matters when you mix await with setTimeout. An await on a resolved promise schedules a microtask, which runs before any pending setTimeout callback. In practice this means code after an await often runs before callbacks scheduled with a zero-delay timeout, which surprises developers who expect immediate execution.
If you need to ensure a callback runs after all microtasks have completed, use setTimeout(fn, 0) or queueMicrotask(() => setTimeout(fn, 0)). The latter explicitly yields to the macrotask queue first, then schedules the callback for the next macrotask turn.
Number Precision
JavaScript uses IEEE 754 double-precision floating point numbers. This means integers up to Number.MAX_SAFE_INTEGER (9007199254740991) are represented exactly. Operations outside this range lose precision. 0.1 + 0.2 does not equal 0.3 exactly. It equals 0.30000000000000004 due to binary floating point representation. For financial calculations or any domain where exact decimal arithmetic matters, do not use native numbers. Use a library like decimal.js or big.js, or work entirely in integers by scaling values. Multiplying by 100, performing calculations, then dividing back is a common approach for currency. It avoids floating point errors entirely as long as all intermediate values stay within safe integer range.
Debouncing and Throttling
Debounce and throttle are utility functions that limit how often a callback executes. Debounce delays execution until a pause in events. Throttle limits execution to once per fixed time interval. Both are essential for scroll handlers, resize handlers, and input search. The common mistake is implementing them incorrectly and leaking timers. A debounced function that does not clear the previous timeout on each invocation will execute multiple times instead of once after the last event. A throttled function that does not track the remaining delay will fire too frequently or not at all.
function debounce(fn, delay) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
function throttle(fn, limit) {
let inThrottle = false;
return function (...args) {
if (!inThrottle) {
fn.apply(this, args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}
Both implementations preserve this and forward arguments correctly. They also clean up timers properly, which prevents memory leaks in long-running applications.