JavaScript fundamentals show up in nearly every frontend and full-stack interview — closures, async behavior, and type coercion in particular. Here's what to have sharp before your next round.
A closure is a function that retains access to variables from its enclosing lexical scope even after that outer function has finished executing. This is why, for example, a counter function returned from a factory function can keep incrementing its own private variable across calls — the inner function 'closes over' that variable instead of it being garbage collected. Every function in JavaScript forms a closure over its surrounding scope at the time it's defined, whether or not it visibly uses that fact, which is what makes patterns like private state and memoized functions possible without any special syntax.
JavaScript is single-threaded, but the event loop lets it handle async work without blocking the main thread. Synchronous code runs first on the call stack; once the stack is empty, the microtask queue — Promise .then/.catch/.finally callbacks and queueMicrotask — is drained completely, including any new microtasks queued while draining, before the engine does anything else. Only after the microtask queue is empty does the event loop move on to a single macrotask, such as setTimeout, setInterval, or an I/O callback, per iteration, and in browsers rendering can happen between macrotasks. This ordering is why a Promise.then() callback always runs before a setTimeout(fn, 0) callback, even though both are scheduled asynchronously and the timeout has a 0ms delay.
var is function-scoped and hoisted with an initial value of undefined, allowing access before its declaration line without throwing an error. let and const are block-scoped and are also hoisted, but into a 'temporal dead zone' that spans from the start of the block to the declaration itself — accessing them in that zone throws a ReferenceError instead of returning undefined. const additionally prevents reassignment of the binding, though objects or arrays assigned to a const variable can still be mutated internally, since it's the variable binding that's frozen, not the value it points to. In practice, var is largely avoided in modern code because its function scoping and lack of a temporal dead zone make bugs like loop-variable leakage easy to introduce.
== performs type coercion before comparing, so values of different types can be considered equal, like '5' == 5 being true because the string is coerced to a number first. === compares both value and type without any coercion, so '5' === 5 is false. Using === is the standard recommendation because JavaScript's coercion rules for == are inconsistent enough to produce surprising results, such as '' == 0 and null == undefined both being true while null == 0 is false.
'this' is determined by how a function is called, not where it's defined. In a plain function call it's undefined in strict mode or the global object otherwise; as a method call, obj.method(), it's the object before the dot; with call, apply, or bind it's explicitly set regardless of how the function is later invoked; and in arrow functions, 'this' is lexically inherited from the enclosing scope at definition time rather than being set by the call site, which is why arrow functions are commonly used for callbacks inside methods that need to preserve the outer 'this'.
Every JavaScript object has an internal link, its prototype, to another object it inherits properties and methods from. When you access a property, the engine walks up this prototype chain until it finds the property or reaches null, at which point it returns undefined. Classes in JavaScript are syntactic sugar over this same prototype-based mechanism — a class's methods live on Class.prototype, and class inheritance via extends still ultimately links objects together through prototypes under the hood rather than introducing a separate inheritance model.
Callbacks pass a function to be invoked when an async operation completes, which can lead to deeply nested 'callback hell' when several async steps need to happen in sequence. Promises represent a future value with a chainable .then()/.catch()/.finally() interface, flattening that nesting and giving async errors a single, consistent path to propagate through instead of needing to be checked at every callback. async/await is syntax built on top of Promises that lets you write asynchronous code that reads like synchronous code — an async function always returns a Promise, and await pauses execution within that function, without blocking the rest of the program, until the awaited Promise settles.
Event delegation attaches a single event listener to a common parent element instead of individual listeners on many children, relying on event bubbling — when a child is clicked, the event bubbles up through its ancestors and the parent's handler inspects event.target (or event.target.closest(selector)) to determine which child actually triggered it. This is more memory-efficient than attaching hundreds of listeners for a long list, and it automatically covers elements added to the DOM later, since the listener lives on the parent rather than on each child individually.
Hoisting is JavaScript's behavior of processing declarations before executing any code in a scope, which makes it look as though declarations were moved to the top. Function declarations are hoisted along with their entire body, so they can be called before they appear in the source. var declarations are hoisted but only the name is moved, not the assignment, so the variable exists as undefined until its assignment line actually runs. let and const are hoisted too, but they land in the temporal dead zone rather than being initialized, so referencing them before their declaration throws a ReferenceError instead of silently returning undefined.
Debouncing and throttling both limit how often a function runs in response to rapid-fire events like typing, scrolling, or resizing, but they solve it differently. Debouncing delays execution until a specified pause has occurred since the last call, so if the event keeps firing, the function never runs until it stops — useful for a search-as-you-type box where you only want to fire the API call once the user stops typing. Throttling instead guarantees the function runs at most once per specified interval regardless of how many times the event fires, which suits continuous events like scroll or mousemove handlers where you still want periodic updates while the event is ongoing. Both are typically implemented with a closure that tracks either a pending timer or the timestamp of the last invocation.
map, filter, and reduce are the core higher-order array methods for transforming data without writing manual loops, and all three return new values rather than mutating the original array. map takes a callback and returns a new array of the same length with each element transformed. filter takes a predicate callback and returns a new array containing only the elements for which it returned true. reduce is the most general of the three: it takes a callback and an initial accumulator value and folds the array down into a single value by calling the callback with the running accumulator and each element in turn — map and filter can each be implemented in terms of reduce.
Spread and rest use the same ... syntax but do opposite jobs depending on context. Spread expands an iterable's elements in place, such as copying an array into a new array literal, merging objects with {...a, ...b}, or passing an array's elements as individual arguments to a function call. Rest does the reverse: it collects multiple remaining arguments or properties into a single array or object, as in function(a, ...rest) or const {x, ...others} = obj. A key gotcha with spread is that it only performs a shallow copy, so nested objects or arrays inside the copy are still shared by reference with the original.
Destructuring lets you unpack values from arrays or properties from objects into individual variables in a single expression. Array destructuring is positional, so const [a, b] = arr assigns by index, while object destructuring matches by property name, const {x, y} = obj, and can rename bindings with const {x: renamedX} = obj. Both forms support default values for when the source value is undefined, and both can be nested to pull values out of deeply structured data. It's especially common in function parameters, where destructuring an options object lets you document the expected fields and give them defaults right in the function signature.
undefined is the value JavaScript assigns automatically to a variable that's been declared but not yet assigned, to a missing function argument, or to a property that doesn't exist on an object. null is an intentional value a developer assigns to explicitly represent 'no value' or 'empty'. They're loosely equal, so null == undefined is true, but not strictly equal, so null === undefined is false, because they're different types — famously, typeof null returns 'object', a long-standing quirk kept for backward compatibility, while typeof undefined correctly returns 'undefined'.
NaN, short for 'Not a Number', is the result of invalid numeric operations like 0/0 or Math.sqrt(-1). Its strangest property is that it's the only JavaScript value not equal to itself — NaN === NaN evaluates to false — because the IEEE 754 floating-point spec defines NaN comparisons that way, so you can't detect it with equality operators. The global isNaN() function coerces its argument to a number first, so isNaN('foo') returns true even though 'foo' was never meant to be numeric, whereas Number.isNaN() does no coercion and only returns true for the actual NaN value, making it the safer choice for genuinely checking a value is NaN.
WeakMap and WeakSet are collections similar to Map and Set, but their keys (WeakMap) or values (WeakSet) must be objects and are held with 'weak' references, meaning they don't prevent the garbage collector from reclaiming that object if there are no other references to it elsewhere in the program. Because entries can disappear at any time as objects get collected, neither collection is iterable and neither exposes a size property or a way to list its contents. This makes them well suited for attaching metadata to an object — like caching computed data or tracking DOM nodes — without causing a memory leak by keeping that object alive forever just because it happens to be a key in your map.
typeof returns a string identifying a value's type, like 'string', 'number', 'boolean', 'undefined', or 'function', but it lumps arrays, null, and plain objects all together as 'object'. instanceof instead checks whether an object's prototype chain includes a given constructor's prototype, so arr instanceof Array is true while typeof arr is just 'object'. typeof is generally used to distinguish primitives and to safely check whether something is defined, while instanceof is used to distinguish between different kinds of objects or class instances. Neither is fully reliable across iframe or realm boundaries, where instanceof in particular can fail because each realm has its own separate constructor and prototype objects.
JSON.stringify converts a JavaScript value into a JSON string, but it silently drops properties whose values are undefined, functions, or symbols, and it throws a TypeError on circular references. Special numeric values like NaN and Infinity are converted to null since JSON has no way to represent them, while Date objects are converted to their ISO string representation. JSON.parse does the reverse, but it only understands JSON's own primitive types, so anything that was a function, undefined, a Map, or a Date on the original object comes back as a plain string or is missing entirely — round-tripping an object through JSON.stringify and JSON.parse is therefore lossy for anything beyond plain data. Both methods also accept optional arguments — a replacer or reviver function, and an indentation value for stringify — that let you customize which properties are included or how values are transformed.
JavaScript is garbage collected, but leaks still happen when references to objects are kept alive longer than intended, preventing the collector from ever reclaiming them. Common causes include forgotten event listeners or timers that hold a reference to a DOM node or closure after it's no longer needed, accidental global variables created by omitting a declaration keyword, closures that capture large objects in their scope even though they only need a small piece of them, and detached DOM nodes that are removed from the document but still referenced somewhere in JavaScript. Because the garbage collector only reclaims memory that's truly unreachable, the fix in each case is the same: explicitly clear timers, remove listeners, and null out references when they're no longer needed instead of assuming the runtime will clean them up.
Currying transforms a function that takes multiple arguments into a sequence of functions that each take a single argument, returning a new function until all arguments have been supplied and the original function finally executes. A curried add(a, b, c), for instance, becomes add(a)(b)(c), where each call returns a closure that remembers the arguments seen so far. It's useful for creating specialized versions of a general function by partially applying some arguments up front — like building a multiply(2) function from a generic multiply(a, b) — and it composes naturally with functional-programming patterns like pipe and compose.
CommonJS is Node's original module system, using require() to import and module.exports to export, resolved and executed synchronously at runtime. ES Modules, or ESM, are the standardized JavaScript module system using import and export syntax, and they're statically analyzable — imports and exports must appear at the top level, which lets bundlers determine the full dependency graph and perform tree-shaking without executing the code. ESM also supports live bindings, meaning an imported value reflects later updates made in the exporting module, whereas CommonJS exports a snapshot copy of the value at the time of require. Node supports both today, distinguished by file extension (.mjs vs .cjs) or the 'type' field in package.json, but mixing them requires care since CommonJS can't synchronously import an ES module.
Several Array.prototype methods mutate the array in place — push, pop, shift, unshift, splice, sort, and reverse all modify the original array, which can cause subtle bugs if you assumed you were working with a copy, especially when the array is shared by reference across functions or stored in application state. Others are non-mutating and return a new array instead, such as map, filter, slice, concat, and the newer toSorted, toReversed, and toSpliced added specifically to give the mutating operations non-mutating counterparts. This distinction matters a lot in frameworks like React that rely on reference equality to detect state changes — mutating an array in place won't trigger a re-render because the reference itself didn't change, so the usual practice is to build a new array with spread syntax or one of the non-mutating methods rather than mutate and reassign the same reference.