DEV INDUSTRY Kaj Białas

JavaScript Gotchas and Tricky Behaviors

Back to articles
JavaScript

JavaScript Gotchas and Tricky Behaviors

After a decade of JavaScript development across enterprise applications, startup MVPs, and open-source libraries, I've encountered countless situations where the language's quirky behaviors led to production bugs, performance issues, or head-scratching debugging sessions. While modern frameworks and TypeScript help mitigate many issues, understanding JavaScript's fundamental quirks remains essential for writing robust, maintainable applications.

This comprehensive guide explores the most common JavaScript gotchas that continue to surprise even experienced developers, along with practical strategies, debugging techniques, and architectural patterns to avoid them.

The JavaScript Engine Perspective: Understanding the "Why"

Before diving into specific gotchas, it's crucial to understand why JavaScript behaves the way it does. Many seemingly "weird" behaviors stem from the language's original design constraints and the need for backward compatibility.

Historical Context and Design Decisions

JavaScript was created in just 10 days at Netscape in 1995, with several design goals that still influence the language today:

  1. Dynamic typing for rapid prototyping
  2. Automatic type coercion to be "forgiving" to beginners
  3. Prototype-based inheritance for object flexibility
  4. Event-driven execution for browser interactivity

Understanding these goals helps explain why JavaScript prioritizes flexibility over strict type safety, leading to many of the gotchas we'll explore.

Variable Declaration Pitfalls: Scope, Hoisting, and Temporal Dead Zone

Understanding JavaScript's variable declaration mechanisms is crucial for avoiding scope-related bugs that can be particularly nasty to debug in large codebases.

The var Hoisting Trap: Function Scope vs Block Scope

The var keyword creates function-scoped variables with hoisting behavior that often surprises developers coming from block-scoped languages:

1function demonstrateVarHoisting() {
2  console.log('Value of message:', message); // undefined (not ReferenceError!)
3  console.log('Type of message:', typeof message); // "undefined"
4  
5  if (false) {
6    var message = "This never executes";
7  }
8  
9  console.log('Message after if:', message); // still undefined
10  
11  // Even in loops, var creates function-scoped variables
12  for (var i = 0; i < 3; i++) {
13    // Loop logic
14  }
15  console.log('i after loop:', i); // 3 - i is accessible outside the loop!
16}
17
18// What JavaScript actually sees after hoisting:
19function demonstrateVarHoisting() {
20  var message; // hoisted declaration
21  var i; // hoisted declaration
22  
23  console.log('Value of message:', message); // undefined
24  console.log('Type of message:', typeof message); // "undefined"
25  
26  if (false) {
27    message = "This never executes"; // assignment stays in place
28  }
29  
30  console.log('Message after if:', message); // undefined
31  
32  for (i = 0; i < 3; i++) {
33    // Loop logic
34  }
35  console.log('i after loop:', i); // 3
36}

Real-world Impact: This behavior can lead to subtle bugs in complex applications where variables are unintentionally shared across different parts of a function:

1function processUsers(users) {
2  var processedCount = 0;
3  
4  if (users.length > 0) {
5    var message = "Processing users...";
6    console.log(message);
7    
8    for (var i = 0; i < users.length; i++) {
9      // Complex processing logic here
10      processedCount++;
11      
12      if (users[i].needsSpecialHandling) {
13        var specialMessage = `Special handling for user ${i}`;
14        // More logic...
15      }
16    }
17  }
18  
19  // These variables are all accessible here due to function scoping!
20  console.log(message); // "Processing users..." or undefined
21  console.log(i); // users.length
22  console.log(specialMessage); // Last special message or undefined
23  
24  return processedCount;
25}

Temporal Dead Zone: Advanced let/const Behaviors

let and const have their own pitfalls with the Temporal Dead Zone (TDZ), which is the period between entering scope and variable declaration:

1function temporalDeadZoneExample() {
2  // TDZ starts here for 'value'
3  
4  console.log('Before declaration...');
5  
6  // These all throw ReferenceError, not "undefined"
7  console.log(typeof value); // ReferenceError: Cannot access 'value' before initialization
8  console.log(value); // ReferenceError: Cannot access 'value' before initialization
9  
10  // Even checking with try/catch doesn't help
11  try {
12    if (value) {
13      console.log('Value exists');
14    }
15  } catch (e) {
16    console.log('Caught error:', e.message);
17  }
18  
19  let value = 42; // TDZ ends here
20  console.log('After declaration:', value); // 42
21}
22
23// Compare with var behavior:
24function varExample() {
25  console.log('Before declaration...');
26  console.log(typeof value); // "undefined" - no error
27  console.log(value); // undefined - no error
28  
29  var value = 42;
30  console.log('After declaration:', value); // 42
31}
32
33// TDZ applies even with destructuring
34function destructuringTDZ() {
35  // This throws ReferenceError, not undefined
36  console.log(x); // ReferenceError
37  
38  let [x, y] = [1, 2];
39}
40
41// Class expressions also have TDZ
42const MyClass = class extends Base {
43  static {
44    // Can't access MyClass here - ReferenceError
45    console.log(MyClass); // ReferenceError: Cannot access 'MyClass' before initialization
46  }
47};

Advanced TDZ Gotcha - Function Parameters:

1function parameterTDZ(a = b, b = 2) {
2  return [a, b];
3}
4
5parameterTDZ(); // ReferenceError: Cannot access 'b' before initialization
6parameterTDZ(1, 2); // [1, 2] - works fine when not using defaults

Practical Solutions:

1// Solution 1: Declare variables at the top of their scope
2function betterExample() {
3  let value; // Declare early to avoid TDZ issues
4  let result;
5  
6  // Complex logic here...
7  
8  if (someCondition) {
9    value = calculateValue(); // Assign when needed
10    result = processValue(value);
11  }
12  
13  return result || defaultValue;
14}
15
16// Solution 2: Use block scoping intentionally
17function blockScopingExample() {
18  const users = getUsers();
19  
20  if (users.length > 0) {
21    const message = "Processing users..."; // Block-scoped
22    console.log(message);
23    
24    for (const user of users) { // const in for...of creates new binding each iteration
25      const userId = user.id; // Block-scoped to this iteration
26      processUser(userId);
27    }
28    // message, userId are not accessible here
29  }
30  
31  // Only users is accessible here
32  return users;
33}

Advanced Hoisting: Function Declarations vs Expressions

Function hoisting has its own set of gotchas that can lead to unexpected behavior:

1// Function declarations are fully hoisted
2console.log(foo()); // "Function declaration works!"
3
4function foo() {
5  return "Function declaration works!";
6}
7
8// Function expressions are not hoisted
9console.log(bar()); // TypeError: bar is not a function
10
11var bar = function() {
12  return "Function expression";
13};
14
15// Arrow functions follow the same rules as function expressions
16console.log(baz()); // TypeError: baz is not a function
17
18var baz = () => "Arrow function";
19
20// Class declarations have hoisting but are in TDZ
21console.log(new MyClass()); // ReferenceError: Cannot access 'MyClass' before initialization
22
23class MyClass {
24  constructor() {
25    this.name = "MyClass";
26  }
27}

Conditional Function Declarations - Browser Inconsistencies:

1// This behavior is inconsistent across environments!
2function conditionalFunction() {
3  if (true) {
4    function innerFunction() {
5      return "inner";
6    }
7  }
8  
9  return innerFunction(); // May work or throw error depending on environment
10}
11
12// Better approach: Use function expressions for conditional functions
13function betterConditionalFunction() {
14  let innerFunction;
15  
16  if (true) {
17    innerFunction = function() {
18      return "inner";
19    };
20  }
21  
22  return innerFunction ? innerFunction() : "default";
23}

Prototype Chain Surprises: Inheritance and Context

JavaScript's prototypal inheritance system has several behaviors that can catch developers off guard, especially when dealing with complex object hierarchies.

Method Context Loss: The this Binding Problem

One of the most common issues involves losing method context when passing methods as callbacks:

1class APIClient {
2  constructor(baseURL, apiKey) {
3    this.baseURL = baseURL;
4    this.apiKey = apiKey;
5    this.requestCount = 0;
6  }
7  
8  async fetchData(endpoint) {
9    this.requestCount++; // This line will fail if context is lost
10    
11    const response = await fetch(`${this.baseURL}${endpoint}`, {
12      headers: {
13        'Authorization': `Bearer ${this.apiKey}`,
14        'Content-Type': 'application/json'
15      }
16    });
17    
18    if (!response.ok) {
19      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
20    }
21    
22    return response.json();
23  }
24  
25  getRequestCount() {
26    return this.requestCount;
27  }
28}
29
30const client = new APIClient('https://api.example.com', 'secret-key');
31
32// Scenario 1: Direct method call - works fine
33client.fetchData('/users/1').then(console.log);
34
35// Scenario 2: Method extracted - THIS BREAKS!
36const fetchUser = client.fetchData;
37fetchUser('/users/1'); // TypeError: Cannot read property 'requestCount' of undefined
38
39// Scenario 3: Used as callback - THIS ALSO BREAKS!
40const endpoints = ['/users/1', '/users/2', '/users/3'];
41Promise.all(endpoints.map(client.fetchData)); // TypeError for each call
42
43// Solutions:
44// 1. Bind the method
45const boundFetchUser = client.fetchData.bind(client);
46Promise.all(endpoints.map(boundFetchUser)); // Works!
47
48// 2. Use arrow function wrapper
49const wrappedFetchUser = (endpoint) => client.fetchData(endpoint);
50Promise.all(endpoints.map(wrappedFetchUser)); // Works!
51
52// 3. Use arrow methods in class (creates instance methods, not prototype methods)
53class APIClientFixed {
54  constructor(baseURL, apiKey) {
55    this.baseURL = baseURL;
56    this.apiKey = apiKey;
57    this.requestCount = 0;
58  }
59  
60  fetchData = async (endpoint) => {
61    this.requestCount++;
62    // ... rest of implementation
63  }
64  
65  // Note: Arrow methods are created per instance, not on prototype
66  // This uses more memory but avoids context issues
67}

Memory and Performance Implications:

1// Prototype methods (shared across instances)
2class RegularClass {
3  method() {
4    return this.value;
5  }
6}
7
8// Arrow methods (created per instance)
9class ArrowClass {
10  method = () => {
11    return this.value;
12  }
13}
14
15// Memory usage comparison
16const regular1 = new RegularClass();
17const regular2 = new RegularClass();
18console.log(regular1.method === regular2.method); // true - shared prototype method
19
20const arrow1 = new ArrowClass();
21const arrow2 = new ArrowClass();
22console.log(arrow1.method === arrow2.method); // false - separate instances

Call, Apply, and Bind: Advanced Context Manipulation

These methods offer powerful context control but have subtle differences and gotchas:

1const person = {
2  name: 'Alice',
3  age: 30,
4  greet(greeting, punctuation = '.') {
5    return `${greeting}, ${this.name}${punctuation} I am ${this.age} years old`;
6  },
7  
8  introduce: function(...hobbies) {
9    const hobbyList = hobbies.length > 0 ? hobbies.join(', ') : 'nothing special';
10    return `Hi, I'm ${this.name} and I like ${hobbyList}`;
11  }
12};
13
14const anotherPerson = { name: 'Bob', age: 25 };
15
16// call: arguments passed individually
17console.log(person.greet.call(anotherPerson, 'Hello', '!')); 
18// "Hello, Bob! I am 25 years old"
19
20// apply: arguments passed as array
21console.log(person.greet.apply(anotherPerson, ['Hi', '.'])); 
22// "Hi, Bob. I am 25 years old"
23
24// bind: returns new function with fixed context
25const boundGreet = person.greet.bind(anotherPerson);
26console.log(boundGreet('Hey', '?')); 
27// "Hey, Bob? I am 25 years old"
28
29// Advanced usage with rest parameters
30console.log(person.introduce.call(anotherPerson, 'reading', 'coding', 'music'));
31// "Hi, I'm Bob and I like reading, coding, music"
32
33console.log(person.introduce.apply(anotherPerson, ['reading', 'coding', 'music']));
34// Same result as above
35
36// Partial application with bind
37const bobIntroducer = person.introduce.bind(anotherPerson, 'reading');
38console.log(bobIntroducer('coding', 'music'));
39// "Hi, I'm Bob and I like reading, coding, music"
40
41// Arrow functions ignore bind/call/apply context!
42const arrowGreet = (greeting, punctuation) => {
43  return `${greeting}, ${this.name}${punctuation}`; // 'this' is lexically bound
44};
45
46// This won't work as expected in browser (this = window) or Node.js (this = undefined)
47console.log(arrowGreet.call(person, 'Hello', '!')); // "Hello, undefined!" or error
48
49// Real-world example: Event handlers
50class ButtonHandler {
51  constructor(element) {
52    this.element = element;
53    this.clickCount = 0;
54    
55    // Problem: this will be the button element, not our class instance
56    this.element.addEventListener('click', this.handleClick);
57    
58    // Solution 1: Bind in constructor
59    this.element.addEventListener('click', this.handleClick.bind(this));
60    
61    // Solution 2: Arrow function (if using arrow method)
62    this.element.addEventListener('click', this.handleClickArrow);
63  }
64  
65  handleClick(event) {
66    // 'this' refers to the button element, not ButtonHandler instance
67    console.log(this.clickCount); // undefined
68  }
69  
70  handleClickArrow = (event) => {
71    // 'this' refers to ButtonHandler instance
72    this.clickCount++;
73    console.log(`Button clicked ${this.clickCount} times`);
74  }
75}

Object Cloning Pitfalls: Deep Dive into Reference vs Value

Object and array cloning in JavaScript has several gotchas that can lead to unexpected mutations, especially in complex applications with nested data structures.

Shallow vs Deep Copy: Comprehensive Analysis

1const complexUser = {
2  id: 'user123',
3  name: 'John Doe',
4  age: 30,
5  
6  // Nested objects
7  address: {
8    street: '123 Main St',
9    city: 'Anytown',
10    coordinates: {
11      lat: 40.7128,
12      lng: -74.0060
13    }
14  },
15  
16  // Arrays with objects
17  hobbies: ['reading', 'coding'],
18  projects: [
19    { name: 'Project A', status: 'active' },
20    { name: 'Project B', status: 'completed' }
21  ],
22  
23  // Special types
24  createdAt: new Date('2023-01-01'),
25  preferences: new Map([
26    ['theme', 'dark'],
27    ['language', 'en']
28  ]),
29  tags: new Set(['developer', 'javascript', 'react']),
30  
31  // Functions
32  greet() {
33    return `Hello, I'm ${this.name}`;
34  },
35  
36  // Symbols
37  [Symbol('secret')]: 'hidden value'
38};
39
40console.log('=== Shallow Copy Analysis ===');
41
42// Method 1: Spread operator
43const spreadCopy = { ...complexUser };
44console.log('Spread copy - same reference check:');
45console.log('address same?', spreadCopy.address === complexUser.address); // true (problem!)
46console.log('hobbies same?', spreadCopy.hobbies === complexUser.hobbies); // true (problem!)
47
48// Demonstrate the problem
49spreadCopy.address.city = 'New City';
50console.log('Original city after spread modification:', complexUser.address.city); // 'New City' - modified!
51
52// Method 2: Object.assign
53const assignCopy = Object.assign({}, complexUser);
54assignCopy.projects[0].status = 'paused';
55console.log('Original project status after assign modification:', complexUser.projects[0].status); // 'paused' - modified!
56
57console.log('\n=== Deep Copy Analysis ===');
58
59// Method 1: JSON.parse(JSON.stringify()) - Limited but fast
60const jsonCopy = JSON.parse(JSON.stringify(complexUser));
61console.log('JSON copy result:');
62console.log('Has createdAt?', 'createdAt' in jsonCopy); // true
63console.log('createdAt type:', typeof jsonCopy.createdAt); // 'string' (not Date!)
64console.log('Has preferences?', 'preferences' in jsonCopy); // true
65console.log('preferences type:', jsonCopy.preferences.constructor.name); // 'Object' (not Map!)
66console.log('Has greet method?', 'greet' in jsonCopy); // false (function lost!)
67console.log('Has symbol property?', Object.getOwnPropertySymbols(jsonCopy).length); // 0 (symbol lost!)
68
69// Method 2: structuredClone (modern browsers)
70if (typeof structuredClone !== 'undefined') {
71  const structuredCopy = structuredClone(complexUser);
72  console.log('\nstructuredClone copy result:');
73  console.log('Has createdAt?', 'createdAt' in structuredCopy); // true
74  console.log('createdAt type:', structuredCopy.createdAt.constructor.name); // 'Date' (preserved!)
75  console.log('Has preferences?', 'preferences' in structuredCopy); // true
76  console.log('preferences type:', structuredCopy.preferences.constructor.name); // 'Map' (preserved!)
77  console.log('Has greet method?', 'greet' in structuredCopy); // false (functions still not supported)
78  
79  // Test independence
80  structuredCopy.address.city = 'Structured City';
81  console.log('Original city after structured modification:', complexUser.address.city); // unchanged!
82} else {
83  console.log('structuredClone not available in this environment');
84}

Asynchronous JavaScript Gotchas: Advanced Patterns and Pitfalls

Asynchronous code in JavaScript can be particularly tricky, with several common pitfalls that can lead to race conditions, memory leaks, and unexpected behavior.

Promise Chain Pitfalls: Advanced Error Handling

1// Common mistakes in promise chains and their solutions
2console.log('=== Promise Chain Analysis ===');
3
4// Problem 1: Not returning promises (swallowed promises)
5function badPromiseChain() {
6  return fetchUser()
7    .then(user => {
8      processUser(user); // Missing return! This promise is lost
9      console.log('User processed'); // This runs immediately
10    })
11    .then(result => {
12      console.log('Result:', result); // undefined - processUser result is lost
13      return result;
14    });
15}
16
17// Problem 2: Mixing sync and async error handling
18function mixedErrorHandling() {
19  return fetchUser()
20    .then(user => {
21      if (!user) {
22        throw new Error('User not found'); // This will be caught
23      }
24      
25      const result = processUserSync(user); // If this throws, it will also be caught
26      
27      if (result.error) {
28        throw new Error(result.error); // Proper async error
29      }
30      
31      return result;
32    })
33    .catch(error => {
34      console.error('Caught error:', error.message);
35      // Should we re-throw or return a default value?
36      throw error; // Re-throw to let caller handle
37    });
38}
39
40// Better approach: Controlled parallel processing with error handling
41async function betterAsyncProcessing(items, { concurrency = 3, failFast = false } = {}) {
42  const processWithRetry = async (item, retries = 2) => {
43    for (let attempt = 0; attempt <= retries; attempt++) {
44      try {
45        return await processItem(item);
46      } catch (error) {
47        if (attempt === retries) {
48          console.error(`Failed to process item ${item.id} after ${retries + 1} attempts:`, error);
49          if (failFast) throw error;
50          return { error: error.message, item: item.id };
51        }
52        
53        // Exponential backoff
54        await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
55      }
56    }
57  };
58  
59  // Process in batches for controlled concurrency
60  const results = [];
61  for (let i = 0; i < items.length; i += concurrency) {
62    const batch = items.slice(i, i + concurrency);
63    const batchResults = await Promise.all(
64      batch.map(item => processWithRetry(item))
65    );
66    results.push(...batchResults);
67  }
68  
69  return results;
70}
71
72// Problem 4: Race conditions with shared state
73class DataManager {
74  constructor() {
75    this.cache = new Map();
76    this.loading = new Map(); // Track ongoing requests
77  }
78  
79  // Problematic: Multiple calls might trigger multiple fetches
80  async getData(key) {
81    if (this.cache.has(key)) {
82      return this.cache.get(key);
83    }
84    
85    // Problem: If called multiple times quickly, multiple fetches happen
86    const data = await fetch(`/api/data/${key}`).then(r => r.json());
87    this.cache.set(key, data);
88    return data;
89  }
90  
91  // Better: Prevent race conditions
92  async getDataSafe(key) {
93    if (this.cache.has(key)) {
94      return this.cache.get(key);
95    }
96    
97    // If already loading, return the same promise
98    if (this.loading.has(key)) {
99      return this.loading.get(key);
100    }
101    
102    // Create and cache the promise
103    const promise = fetch(`/api/data/${key}`)
104      .then(r => r.json())
105      .then(data => {
106        this.cache.set(key, data);
107        this.loading.delete(key); // Clean up
108        return data;
109      })
110      .catch(error => {
111        this.loading.delete(key); // Clean up on error too
112        throw error;
113      });
114    
115    this.loading.set(key, promise);
116    return promise;
117  }
118}

Event Loop Order: Advanced Execution Patterns

1// Advanced event loop behavior and gotchas
2console.log('=== Advanced Event Loop Analysis ===');
3
4function demonstrateEventLoopOrder() {
5  console.log('1: Synchronous start');
6  
7  // Macrotask
8  setTimeout(() => console.log('2: setTimeout 0ms'), 0);
9  
10  // Immediate macrotask (Node.js only)
11  if (typeof setImmediate !== 'undefined') {
12    setImmediate(() => console.log('3: setImmediate'));
13  }
14  
15  // Microtasks
16  Promise.resolve().then(() => {
17    console.log('4: Promise.resolve');
18    
19    // Nested microtask
20    return Promise.resolve();
21  }).then(() => {
22    console.log('5: Nested Promise');
23  });
24  
25  queueMicrotask(() => {
26    console.log('6: queueMicrotask');
27    
28    // This creates another microtask
29    queueMicrotask(() => console.log('7: Nested queueMicrotask'));
30  });
31  
32  // Another macrotask
33  setTimeout(() => {
34    console.log('8: setTimeout 0ms #2');
35    
36    // Microtask from within macrotask
37    Promise.resolve().then(() => console.log('9: Promise from setTimeout'));
38  }, 0);
39  
40  console.log('10: Synchronous end');
41}
42
43// Expected output:
44// 1, 10, 4, 6, 5, 7, 2, 9, 8 (3 between 2 and 9 in Node.js)
45
46// Gotcha: Microtask starvation
47function microtaskStarvation() {
48  console.log('Starting microtask starvation demo...');
49  
50  let count = 0;
51  
52  function recursiveMicrotask() {
53    if (count < 5) {
54      count++;
55      console.log(`Microtask ${count}`);
56      
57      // This prevents macrotasks from running!
58      queueMicrotask(recursiveMicrotask);
59    }
60  }
61  
62  // This setTimeout might be significantly delayed
63  setTimeout(() => console.log('Finally, a macrotask!'), 0);
64  
65  recursiveMicrotask();
66  
67  // In real scenarios, this could block rendering in browsers
68}
69
70// Solution: Yield control periodically
71function responsiveMicrotasks() {
72  console.log('Starting responsive microtask processing...');
73  
74  let count = 0;
75  
76  function processWithYielding() {
77    if (count < 1000) {
78      count++;
79      
80      // Process in batches, yielding every 10 iterations
81      if (count % 10 === 0) {
82        setTimeout(processWithYielding, 0); // Yield to macrotask queue
83      } else {
84        queueMicrotask(processWithYielding);
85      }
86    } else {
87      console.log('Processing complete:', count);
88    }
89  }
90  
91  setTimeout(() => console.log('Macrotask executed during processing'), 5);
92  
93  processWithYielding();
94}

Type Coercion Surprises: Deep Dive into JavaScript's Type System

JavaScript's type coercion system is one of its most misunderstood features, leading to unexpected behavior and bugs.

Comprehensive Type Coercion Analysis

1console.log('=== Comprehensive Type Coercion Analysis ===');
2
3// The infamous equality operator gotchas
4console.log('--- Equality Operator Gotchas ---');
5
6// Basic cases that break mathematical properties
7console.log('0 == "0":', 0 == '0');        // true
8console.log('0 == []:', 0 == []);         // true  
9console.log('"0" == []:', '0' == []);     // false - transitivity broken!
10
11console.log('false == "0":', false == '0');    // true
12console.log('false == []:', false == []);     // true
13console.log('"0" == []:', '0' == []);         // false - transitivity broken again!
14
15// Null and undefined special cases
16console.log('null == undefined:', null == undefined); // true
17console.log('null == 0:', null == 0);                 // false
18console.log('undefined == 0:', undefined == 0);       // false
19console.log('null == "":', null == '');               // false
20console.log('undefined == "":', undefined == '');     // false
21
22// Object to primitive coercion deep dive
23console.log('\n--- Object to Primitive Coercion ---');
24
25// Array coercion
26const arr1 = [];
27const arr2 = [1];
28const arr3 = [1, 2];
29
30console.log('[] toString():', arr1.toString());     // ""
31console.log('[1] toString():', arr2.toString());    // "1"
32console.log('[1,2] toString():', arr3.toString());  // "1,2"
33
34console.log('[] valueOf():', arr1.valueOf());       // [] (returns self)
35console.log('[1] valueOf():', arr2.valueOf());      // [1] (returns self)
36
37// When + operator is used, JavaScript calls valueOf() first, then toString()
38console.log('[] + "":', [] + '');                   // ""
39console.log('[1] + "":', [1] + '');                 // "1"
40console.log('[1,2] + "":', [1, 2] + '');            // "1,2"
41
42// Object coercion
43const obj = { name: 'test' };
44console.log('obj toString():', obj.toString());     // "[object Object]"
45console.log('obj valueOf():', obj.valueOf());       // { name: 'test' } (returns self)
46
47console.log('obj + "":', obj + '');                 // "[object Object]"
48console.log('obj + 1:', obj + 1);                   // "[object Object]1"
49
50// Custom valueOf and toString
51const customObj = {
52  value: 42,
53  toString() {
54    console.log('toString called');
55    return this.value.toString();
56  },
57  valueOf() {
58    console.log('valueOf called');
59    return this.value;
60  }
61};
62
63console.log('\nCustom object coercion:');
64console.log('customObj + "":', customObj + '');     // Calls valueOf first
65console.log('String(customObj):', String(customObj)); // Calls toString
66console.log('Number(customObj):', Number(customObj)); // Calls valueOf
67
68// Date object special behavior
69const date = new Date('2023-01-01');
70console.log('\nDate coercion:');
71console.log('date + "":', date + '');               // Calls toString
72console.log('date + 0:', date + 0);                 // Calls valueOf (returns timestamp)
73
74// Symbol coercion (throws errors)
75const sym = Symbol('test');
76console.log('\nSymbol coercion attempts:');
77try {
78  console.log('sym + "":', sym + '');
79} catch (e) {
80  console.log('Symbol string coercion error:', e.message);
81}
82
83try {
84  console.log('Number(sym):', Number(sym));
85} catch (e) {
86  console.log('Symbol number coercion error:', e.message);
87}
88
89// But this works:
90console.log('String(sym):', String(sym));           // "Symbol(test)"
91console.log('sym.toString():', sym.toString());     // "Symbol(test)"
92
93// Advanced number coercion cases
94console.log('\n--- Advanced Number Coercion ---');
95
96console.log('+"" (empty string):', +'');           // 0
97console.log('+[] (empty array):', +[]);            // 0
98console.log('+[42] (single number):', +[42]);      // 42
99console.log('+[1,2] (multiple):', +[1,2]);         // NaN
100console.log('+true:', +true);                      // 1
101console.log('+false:', +false);                    // 0
102console.log('+null:', +null);                      // 0
103console.log('+undefined:', +undefined);            // NaN
104
105// Whitespace handling
106console.log('+"   42   ":', +'   42   ');          // 42
107console.log('+"\\n\\t42\\r\\n":', +'\n\t42\r\n'); // 42
108
109// Hexadecimal, octal, binary
110console.log('+"0x10" (hex):', +'0x10');           // 16
111console.log('+"0o10" (octal):', +'0o10');         // 8
112console.log('+"0b10" (binary):', +'0b10');        // 2
113
114// Scientific notation
115console.log('+"1e2":', +'1e2');                   // 100
116console.log('+"1E-2":', +'1E-2');                 // 0.01
117
118// Edge cases
119console.log('+"Infinity":', +'Infinity');         // Infinity
120console.log('+"-Infinity":', +'-Infinity');       // -Infinity
121console.log('+"NaN":', +'NaN');                   // NaN

Boolean Coercion and Truthiness

1console.log('=== Boolean Coercion Deep Dive ===');
2
3// Falsy values (all others are truthy)
4const falsyValues = [
5  false,
6  0,
7  -0,
8  0n,           // BigInt zero
9  '',
10  null,
11  undefined,
12  NaN
13];
14
15console.log('Falsy values:');
16falsyValues.forEach(value => {
17  console.log(`Boolean(${JSON.stringify(value)}):`, Boolean(value));
18});
19
20// Surprising truthy values
21const surprisinglyTruthy = [
22  '0',          // String containing zero
23  ' ',          // String with whitespace
24  'false',      // String containing "false"
25  [],           // Empty array
26  {},           // Empty object
27  function(){}, // Function
28  new Date(),   // Date object
29  /regex/,      // Regular expression
30  Infinity,     // Infinity
31  -Infinity,    // Negative infinity
32];
33
34console.log('\nSurprisingly truthy values:');
35surprisinglyTruthy.forEach(value => {
36  console.log(`Boolean(${value}):`, Boolean(value));
37});
38
39// Logical operators and short-circuiting
40console.log('\n--- Logical Operators Deep Dive ---');
41
42// && operator (returns first falsy value or last value)
43console.log('5 && 3:', 5 && 3);                   // 3
44console.log('0 && 3:', 0 && 3);                   // 0
45console.log('"" && "hello":', '' && 'hello');     // ""
46console.log('null && undefined:', null && undefined); // null
47
48// || operator (returns first truthy value or last value)
49console.log('5 || 3:', 5 || 3);                   // 5
50console.log('0 || 3:', 0 || 3);                   // 3
51console.log('"" || "hello":', '' || 'hello');     // "hello"
52console.log('null || undefined:', null || undefined); // undefined
53
54// Nullish coalescing operator ?? (only null and undefined are "nullish")
55console.log('0 ?? 42:', 0 ?? 42);                 // 0 (different from ||!)
56console.log('"" ?? "default":', '' ?? 'default'); // ""
57console.log('null ?? "default":', null ?? 'default'); // "default"
58console.log('undefined ?? "default":', undefined ?? 'default'); // "default"
59
60// Common gotchas with default values
61function problematicDefaults(value) {
62  // Problem: 0, "", false are valid values but get replaced
63  const result = value || 'default';
64  return result;
65}
66
67function betterDefaults(value) {
68  // Better: Only replace null/undefined
69  const result = value ?? 'default';
70  return result;
71}
72
73console.log('\nDefault value comparison:');
74console.log('problematicDefaults(0):', problematicDefaults(0));     // "default" (wrong!)
75console.log('betterDefaults(0):', betterDefaults(0));               // 0 (correct!)
76console.log('problematicDefaults(""):', problematicDefaults(''));   // "default" (wrong!)
77console.log('betterDefaults(""):', betterDefaults(''));             // "" (correct!)

Performance and Memory Gotchas

Understanding performance implications of JavaScript's behaviors is crucial for building scalable applications.

Memory Leaks and Reference Management

1console.log('=== Memory Management Gotchas ===');
2
3// Gotcha 1: Closures holding references
4function createMemoryLeak() {
5  const largeArray = new Array(1000000).fill('data');
6  
7  return function() {
8    // This closure holds a reference to largeArray
9    // even if we don't use it
10    return 'Hello';
11  };
12}
13
14const leakyFunction = createMemoryLeak();
15// largeArray is still in memory because of the closure!
16
17// Better approach: Explicit cleanup
18function createBetterClosure() {
19  let largeArray = new Array(1000000).fill('data');
20  
21  const result = function() {
22    return 'Hello';
23  };
24  
25  // Explicitly clear reference if not needed
26  largeArray = null;
27  
28  return result;
29}
30
31// Gotcha 2: Event listeners not removed
32class ProblematicComponent {
33  constructor(element) {
34    this.element = element;
35    this.data = new Array(100000).fill('data');
36    
37    // This creates a reference cycle
38    this.element.addEventListener('click', this.handleClick.bind(this));
39    
40    // Global event listener holding component reference
41    window.addEventListener('resize', this.handleResize.bind(this));
42  }
43  
44  handleClick() {
45    console.log('Clicked');
46  }
47  
48  handleResize() {
49    console.log('Resized');
50  }
51  
52  // If destroy() is never called, memory leaks occur
53  destroy() {
54    // Must remove event listeners
55    this.element.removeEventListener('click', this.handleClick);
56    window.removeEventListener('resize', this.handleResize);
57    
58    // Clear references
59    this.element = null;
60    this.data = null;
61  }
62}
63
64// Better approach with AbortController
65class BetterComponent {
66  constructor(element) {
67    this.element = element;
68    this.data = new Array(100000).fill('data');
69    this.abortController = new AbortController();
70    
71    const signal = this.abortController.signal;
72    
73    // All listeners can be removed with one abort() call
74    this.element.addEventListener('click', this.handleClick, { signal });
75    window.addEventListener('resize', this.handleResize, { signal });
76  }
77  
78  handleClick = () => {
79    console.log('Clicked');
80  }
81  
82  handleResize = () => {
83    console.log('Resized');
84  }
85  
86  destroy() {
87    // Removes all listeners at once
88    this.abortController.abort();
89    this.element = null;
90    this.data = null;
91  }
92}

Key Takeaways and Best Practices

After exploring JavaScript's gotchas in depth, here are the essential principles for writing robust JavaScript code:

Universal Principles

  1. Embrace Strict Mode: Always use strict mode ('use strict') to catch common mistakes
  2. Prefer Explicit Over Implicit: Use === instead of ==, explicit type conversion instead of coercion
  3. Validate Early and Often: Implement comprehensive input validation at system boundaries
  4. Use Modern Language Features: Leverage const/let, optional chaining, nullish coalescing, and other ES6+ features
  5. Implement Defensive Programming: Always assume inputs might be unexpected or malformed

Development Workflow

  1. Static Analysis: Use ESLint with strict rules and TypeScript for compile-time safety
  2. Runtime Validation: Implement schema validation for all external data
  3. Comprehensive Testing: Test with edge cases, not just happy path scenarios
  4. Code Reviews: Focus on potential gotchas during peer reviews
  5. Performance Monitoring: Track memory usage and performance in production

Architectural Patterns

  1. Immutable Data Structures: Prefer immutable updates to prevent accidental mutations
  2. Pure Functions: Write functions without side effects when possible
  3. Error Boundaries: Implement proper error handling at all levels
  4. Resource Cleanup: Always clean up event listeners, timeouts, and other resources
  5. Type Safety: Use TypeScript or runtime type checking for critical paths

JavaScript's quirky behaviors stem from its flexible nature and historical decisions. While these gotchas can be frustrating, understanding them deeply makes you a more effective developer. The key is not to avoid JavaScript's complexity, but to understand it well enough to use it safely and effectively.

Remember: every "gotcha" is an opportunity to learn something fundamental about how JavaScript works. Embrace the quirks, understand the why behind them, and use that knowledge to write more robust, maintainable code that stands the test of time.

About me

About me

Tech Leader and Front-end Developer with 10 years of experience, placing a particular emphasis on application architecture, understanding of business domains, and the testability of developed solutions.

As a trainer and course author, I attach great importance to the quality aspects of projects, as well as to enhancing team competence and awareness.

I am an enthusiast of the Domain Driven Design approach, a DevOps practitioner, and a dedicated fan of the React ecosystem.

In my free time, I relax to the sounds of hard rock and enjoy a glass of good whisky.

01 Front-end Development
02 Solution Architecture
03 Unit Testing
04 Dev Ops
05 React
Zdjęcie Kaj Białas - Frontend Tech Lead