Back

JavaScript Pitfalls: Five Issues You'll See Again and Again

JavaScript Pitfalls: Five Issues You'll See Again and Again

You’ve shipped code that passed linting, worked in development, and still broke in production. The bug looked obvious in hindsight—a missing await, a mutated array, a this that pointed somewhere unexpected. These JavaScript pitfalls persist because the language’s flexibility creates subtle traps that modern tooling doesn’t always catch.

Here are five common JS mistakes that continue appearing in real-world codebases, along with practical ways to avoid them.

Key Takeaways

  • Use strict equality (===) to avoid unexpected type coercion behavior
  • Arrow functions preserve this from their enclosing scope, while regular functions bind this dynamically
  • Prefer const and declare variables at the top of their scope to avoid temporal dead zone errors
  • Use Promise.all for parallel async operations and Promise.allSettled when you need partial results
  • Use non-mutating array methods like toSorted() and structuredClone() for deep copying

Type Coercion Still Surprises

JavaScript’s loose equality operator (==) performs type coercion, producing results that seem illogical until you understand the underlying algorithm.

0 == '0'           // true
0 == ''            // true
'' == '0'          // false
null == undefined  // true
[] == false        // true

The fix is straightforward: use strict equality (===) everywhere. But coercion appears in other contexts too. The + operator concatenates when either operand is a string:

const quantity = '5'
const total = quantity + 3 // '53', not 8

Modern JavaScript best practices suggest explicit conversion with Number(), String(), or template literals when intent matters. Nullish coalescing (??) helps here too—it only falls back on null or undefined, unlike || which treats 0 and '' as falsy.

The this Binding Problem

The value of this depends on how a function is called, not where it’s defined. This remains one of the most persistent JavaScript pitfalls.

const user = {
  name: 'Alice',
  greet() {
    console.log(this.name)
  }
}

const greet = user.greet
greet() // undefined—'this' is now the global object

Arrow functions capture this from their enclosing scope, which solves some problems but creates others when you actually need dynamic binding:

const user = {
  name: 'Alice',
  greet: () => {
    console.log(this.name) // 'this' refers to outer scope, not 'user'
  }
}

Use arrow functions for callbacks where you want to preserve context. Use regular functions for object methods. When passing methods as callbacks, bind explicitly or wrap in an arrow function.

Hoisting and the Temporal Dead Zone

Variables declared with let and const are hoisted but not initialized, creating a temporal dead zone (TDZ) where accessing them throws a ReferenceError:

console.log(x) // ReferenceError
let x = 5

This differs from var, which hoists and initializes to undefined. The TDZ exists from the start of the block until the declaration is evaluated.

Function declarations hoist completely, but function expressions don’t:

foo() // works
bar() // TypeError: bar is not a function

function foo() {}
const bar = function() {}

Declare variables at the top of their scope and prefer const by default. This eliminates TDZ surprises and signals intent clearly.

Async Pitfalls in JavaScript

Forgetting await is common, but subtler async mistakes cause more damage. Sequential awaits when parallel execution is possible waste time:

// Slow: runs sequentially
const user = await fetchUser()
const posts = await fetchPosts()
const comments = await fetchComments()

// Fast: runs in parallel
const [user, posts, comments] = await Promise.all([
  fetchUser(),
  fetchPosts(),
  fetchComments()
])

Another frequent issue: Promise.all fails fast. If one promise rejects, you lose all results. Use Promise.allSettled when you need partial results:

const results = await Promise.allSettled([fetchA(), fetchB(), fetchC()])
const successful = results.filter(r => r.status === 'fulfilled')

Always handle rejections. Unhandled promise rejections can crash Node processes and cause silent failures in browsers.

Mutation vs Immutability in JavaScript

Mutating arrays and objects creates bugs that are difficult to trace, especially in frameworks with reactive state:

const original = [3, 1, 2]
const sorted = original.sort() // Mutates original!
console.log(original) // [1, 2, 3]

Modern JavaScript provides non-mutating alternatives. Use toSorted(), toReversed(), and with() for arrays. For objects, spread syntax creates shallow copies:

const sorted = original.toSorted()
const updated = { ...user, name: 'Bob' }

Remember that spread creates shallow copies. Nested objects still share references:

const copy = { ...original }
copy.nested.value = 'changed' // Also changes original.nested.value

For deep cloning, use structuredClone() or handle nested structures explicitly.

Conclusion

These five issues—type coercion, this binding, hoisting, async misuse, and accidental mutation—account for a disproportionate share of JavaScript bugs. Recognizing them in code review becomes automatic with practice.

Enable strict ESLint rules like eqeqeq and no-floating-promises. Consider TypeScript for projects where type safety matters. Most importantly, write code that makes intent explicit rather than relying on JavaScript’s implicit behaviors.

FAQs

JavaScript includes both for historical reasons and flexibility. The loose equality operator (==) performs type coercion before comparison, which can be useful but often produces unexpected results. The strict equality operator (===) compares both value and type without coercion. Modern best practice strongly favors === because it makes comparisons predictable and reduces bugs.

Use arrow functions for callbacks, array methods, and situations where you want to preserve the surrounding this context. Use regular functions for object methods, constructors, and cases where you need dynamic this binding. The key difference is that arrow functions lexically bind this from their enclosing scope, while regular functions determine this based on how they are called.

The temporal dead zone (TDZ) is the period between entering a scope and the point where a let or const variable is declared. Accessing the variable during this period throws a ReferenceError. Avoid TDZ issues by declaring variables at the top of their scope and preferring const by default. This makes your code more predictable and easier to read.

Use Promise.all when all promises must succeed for your operation to be meaningful—it fails fast if any promise rejects. Use Promise.allSettled when you need results from all promises regardless of individual failures, such as when fetching data from multiple optional sources. Promise.allSettled returns an array of objects describing each outcome as either fulfilled or rejected.

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay