7 JavaScript function gotchas that trip up experienced developers
Hoisting, a lost this, arrow functions used as methods, closures in var loops and three more cases where a function doesn't do what you expect. Each with an example and the fix.
Published November 3, 2026
Functions look like the easy part of JavaScript. You define one, call it, get a value back. Yet they're behind a surprising share of interview stumbles and bugs that are hard to spot by reading the code. Here are seven that show up in real projects. Try to predict each output before reading the explanation.
1. Declarations are hoisted, expressions aren't
greet(); // "Hello"
function greet() {
console.log('Hello');
}A function declaration is hoisted together with its body, so you can call it before the line where it appears. A function expression doesn't get the same treatment:
sayHi(); // TypeError: sayHi is not a function
var sayHi = function () {
console.log('Hi');
};
wave(); // ReferenceError: Cannot access 'wave' before initialization
const wave = () => console.log('👋');With var, only the variable is hoisted, and it holds undefined until the assignment runs, so there's nothing to call yet. let and const sit in the temporal dead zone until their line, and touching them earlier throws a ReferenceError.
The fix: define functions before you use them, or rely on hoisting only for function declarations, on purpose. Mixing both styles in one file is how people get confused.
2. return followed by a line break
function getUser() {
return
{
name: 'Ann'
};
}
getUser(); // undefinedAutomatic semicolon insertion puts a semicolon right after a bare return. The function returns undefined, and the object below becomes an unreachable block statement. No error, no warning.
The fix: keep the opening brace on the same line: return {. Prettier and ESLint will catch this for you.
3. A method that lost its this
const counter = {
count: 0,
inc() {
this.count += 1;
return this.count;
},
};
counter.inc(); // 1
const inc = counter.inc;
inc(); // TypeError: Cannot read properties of undefined (reading 'count')this depends on how a function is called, not where it's written. counter.inc() is called on the object, so this is counter. Store the method in a variable or pass it as a callback, and that link is gone. In strict mode and in ES modules, this becomes undefined and the call throws. In an old sloppy-mode script it points to the global object instead, and you get a silent NaN.
Callbacks are where this usually bites:
setTimeout(counter.inc, 1000); // this is lost
setTimeout(() => counter.inc(), 1000); // works
setTimeout(counter.inc.bind(counter), 1000); // also worksThe fix: pass an arrow wrapper instead of the bare method, or bind this explicitly.
4. Arrow functions as methods
const timer = {
seconds: 10,
label: () => `seconds: ${this.seconds}`,
};
timer.label(); // "seconds: undefined"Arrow functions don't have their own this; they take it from the scope they were created in. An object literal doesn't create a scope, so this here comes from outside: the global object in a classic script, or undefined in an ES module, where the call throws.
Inside a regular method, though, an arrow function is exactly what you want, because it keeps the method's this:
const timer = {
seconds: 3,
start() {
const id = setInterval(() => {
this.seconds -= 1; // this is timer
if (this.seconds === 0) clearInterval(id);
}, 1000);
},
};The fix: write methods with the regular start() {} syntax and use arrows for the callbacks inside them.
5. Closures in a var loop
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 3, 3, 3All three callbacks close over the same i, because var is scoped to the whole function, not to the loop body. By the time the timers fire, the loop is done and i is 3.
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 0, 1, 2let creates a fresh binding on every iteration, so each callback sees its own value.
The fix: there's no reason to use var in new code. When you find var in a loop with callbacks in older code, look there first.
6. Default parameters and null
function greet(name = 'guest') {
return `Hi, ${name}`;
}
greet(); // "Hi, guest"
greet(undefined); // "Hi, guest"
greet(null); // "Hi, null"
greet(''); // "Hi, "A default kicks in only when the argument is undefined. null, an empty string and 0 are real values, and the function takes them as they are. This shows up a lot with API data, where an empty field often arrives as null.
The fix: if null should also mean "no value", use name ?? 'guest' inside the function.
One nice detail: defaults are evaluated on every call, so unlike Python you won't end up sharing one mutable list between calls:
function add(item, list = []) {
list.push(item);
return list;
}
add(1); // [1]
add(2); // [2]7. map(parseInt)
['1', '2', '3'].map(parseInt); // [1, NaN, NaN]map passes the callback the element, its index and the whole array. parseInt treats its second argument as the radix. So the calls are parseInt('1', 0), parseInt('2', 1) and parseInt('3', 2). A radix of 0 means "figure it out", so the first one works. Radix 1 isn't valid, and 3 isn't a binary digit, hence two NaNs.
The fix: pass the callback explicitly, .map((s) => parseInt(s, 10)), or use .map(Number) when you just need to turn strings into numbers.
What these have in common
Almost every one comes down to functions being values in JavaScript: they get hoisted, passed around, called with a different this and with arguments you didn't plan for. The code often doesn't crash; it quietly returns the wrong thing. That's hard to catch in code review and easy to check with questions: look at five lines and say what ends up in the console.
The tests below are made of exactly that kind of question, and every answer comes with an explanation. Beginner covers functions, values and comparisons; intermediate moves on to this, async code and events.