JavaScript Core Concepts

Core JavaScript mechanics — the event loop, closures, prototypes, this-binding, promises, scoping, and memory.

Explain how the JavaScript event loop works. What's the difference between microtasks and macrotasks?

Answer: The JavaScript event loop is the mechanism that allows JavaScript to handle asynchronous operations despite being single-threaded. Here's how it works:

Components:

  • Call Stack: Executes synchronous code (LIFO - Last In, First Out)
  • Web APIs: Browser APIs for timers, DOM events, HTTP requests
  • Task Queue (Macrotask Queue): For callbacks from setTimeout, setInterval, DOM events
  • Microtask Queue: For callbacks from Promises, queueMicrotask, MutationObserver

Execution Flow:

  1. Execute all synchronous code in the call stack
  2. When call stack is empty, process ALL microtasks first
  3. Then process ONE macrotask
  4. Repeat

Microtasks vs Macrotasks:

  • Microtasks (higher priority): Promise callbacks (.then, .catch, .finally), queueMicrotask(), MutationObserver callbacks
  • Macrotasks (lower priority): setTimeout/setInterval callbacks, DOM event callbacks, I/O operations

Example:

console.log('1'); // Synchronous
setTimeout(() => console.log('2'), 0); // Macrotask
Promise.resolve().then(() => console.log('3')); // Microtask
console.log('4'); // Synchronous
// Output: 1, 4, 3, 2

Follow-up: How would you handle a situation where you need to execute multiple async operations in parallel but want to wait for all of them to complete?

Answer: Use Promise.all() for parallel execution with all-or-nothing behavior, or Promise.allSettled() for handling individual failures:

// All-or-nothing approach
async function fetchMultipleData() {
  try {
    const [users, posts, comments] = await Promise.all([
      fetch('/api/users').then(res => res.json()),
      fetch('/api/posts').then(res => res.json()),
      fetch('/api/comments').then(res => res.json())
    ]);
    return { users, posts, comments };
  } catch (error) {
    console.error('One or more requests failed:', error);
    throw error;
  }
}

// Handle individual failures
async function fetchWithFallback() {
  const results = await Promise.allSettled([
    fetch('/api/users').then(res => res.json()),
    fetch('/api/posts').then(res => res.json()),
    fetch('/api/comments').then(res => res.json())
  ]);
  
  return results.map((result, index) => {
    if (result.status === 'fulfilled') {
      return result.value;
    } else {
      console.error(`Request ${index} failed:`, result.reason);
      return null; // or default value
    }
  });
}

Explain closures and provide a practical use case where closures are essential. What are potential memory leak concerns with closures?

Answer: A closure is a function that has access to variables in its outer (enclosing) scope even after the outer function has returned. The closure "closes over" the variables it needs.

Basic Example:

function outerFunction(x) {
  const outerVariable = x;
  
  function innerFunction(y) {
    console.log(outerVariable + y); // Access to outer scope
  }
  
  return innerFunction;
}

const closure = outerFunction(10);
closure(5); // Output: 15
// outerFunction has finished, but innerFunction still has access to outerVariable

Practical Use Cases:

  1. Data Privacy (Module Pattern):
function createCounter() {
  let count = 0; // Private variable
  
  return {
    increment: () => ++count,
    decrement: () => --count,
    getCount: () => count
  };
}

const counter = createCounter();
console.log(counter.getCount()); // 0
counter.increment();
console.log(counter.getCount()); // 1
// count is not directly accessible from outside
  1. Function Factories:
function createMultiplier(factor) {
  return function(number) {
    return number * factor;
  };
}

const double = createMultiplier(2);
const triple = createMultiplier(3);

console.log(double(5)); // 10
console.log(triple(5)); // 15
  1. Event Handlers with State:
function setupButton(buttonId, clickCount) {
  const button = document.getElementById(buttonId);
  
  button.addEventListener('click', function() {
    clickCount++;
    console.log(`Button clicked ${clickCount} times`);
  });
}

setupButton('myButton', 0);
// Each button maintains its own click count

Memory Leak Concerns:

  • Circular References: Closures can create circular references that prevent garbage collection
  • Large Objects: Keeping references to large objects in closures
  • Event Listeners: Not removing event listeners that reference closures

Prevention:

// Good: Explicit cleanup
function createLeakyClosure() {
  const largeObject = new Array(1000000).fill('data');
  
  return function() {
    // Use largeObject
    return largeObject.length;
  };
}

// Better: Clear references when done
function createSafeClosure() {
  const largeObject = new Array(1000000).fill('data');
  
  const closure = function() {
    return largeObject.length;
  };
  
  // Provide cleanup method
  closure.cleanup = function() {
    largeObject.length = 0; // Clear the array
  };
  
  return closure;
}

How does prototypal inheritance differ from classical inheritance? Explain the prototype chain and how you would implement inheritance in modern JavaScript.

Answer: Classical vs Prototypal Inheritance:

Classical Inheritance:

  • Based on classes (blueprints)
  • Objects are instances of classes
  • Inheritance is defined at compile time
  • Single inheritance (one parent class)
  • Found in Java, C++, C#

Prototypal Inheritance:

  • Based on prototypes (objects)
  • Objects inherit directly from other objects
  • Inheritance is dynamic and flexible
  • Can have multiple prototypes (mixins)
  • Found in JavaScript

Prototype Chain: Every object in JavaScript has a prototype property that points to another object. When you access a property, JavaScript looks up the prototype chain until it finds the property or reaches null.

// Prototype chain example
const animal = {
  type: 'animal',
  speak() {
    console.log('Some sound');
  }
};

const dog = Object.create(animal);
dog.breed = 'Labrador';
dog.speak = function() {
  console.log('Woof!');
};

const puppy = Object.create(dog);
puppy.age = 2;

console.log(puppy.type); // 'animal' (from animal prototype)
console.log(puppy.breed); // 'Labrador' (from dog prototype)
console.log(puppy.age); // 2 (own property)
puppy.speak(); // 'Woof!' (from dog prototype)

Modern JavaScript Inheritance Implementation:

  1. Using ES6 Classes (Syntactic Sugar):
class Animal {
  constructor(name) {
    this.name = name;
  }
  
  speak() {
    console.log(`${this.name} makes a sound`);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);
    this.breed = breed;
  }
  
  speak() {
    console.log(`${this.name} barks`);
  }
  
  fetch() {
    console.log(`${this.name} fetches the ball`);
  }
}

const dog = new Dog('Buddy', 'Golden Retriever');
dog.speak(); // 'Buddy barks'
dog.fetch(); // 'Buddy fetches the ball'
  1. Using Object.create() (True Prototypal):
// Base object
const animal = {
  init(name) {
    this.name = name;
    return this;
  },
  
  speak() {
    console.log(`${this.name} makes a sound`);
  }
};

// Create dog object that inherits from animal
const dog = Object.create(animal);
dog.init = function(name, breed) {
  animal.init.call(this, name);
  this.breed = breed;
  return this;
};

dog.speak = function() {
  console.log(`${this.name} barks`);
};

dog.fetch = function() {
  console.log(`${this.name} fetches the ball`);
};

const buddy = Object.create(dog).init('Buddy', 'Golden Retriever');
buddy.speak(); // 'Buddy barks'
buddy.fetch(); // 'Buddy fetches the ball'
  1. Using Factory Functions:
function createAnimal(name) {
  return {
    name,
    speak() {
      console.log(`${this.name} makes a sound`);
    }
  };
}

function createDog(name, breed) {
  const dog = createAnimal(name);
  dog.breed = breed;
  
  const originalSpeak = dog.speak;
  dog.speak = function() {
    console.log(`${this.name} barks`);
  };
  
  dog.fetch = function() {
    console.log(`${this.name} fetches the ball`);
  };
  
  return dog;
}

const buddy = createDog('Buddy', 'Golden Retriever');
buddy.speak(); // 'Buddy barks'
buddy.fetch(); // 'Buddy fetches the ball'

Key Differences:

  • Flexibility: Prototypal inheritance is more flexible - you can add/remove properties at runtime
  • Memory: Prototypal inheritance can be more memory efficient as methods are shared
  • Multiple Inheritance: Easier to achieve with mixins in prototypal inheritance
  • Performance: Modern engines optimize both approaches similarly

Explain the different ways this can be bound in JavaScript. What are the differences between .call(), .apply(), and .bind()?

Answer: The this keyword in JavaScript refers to the object that is currently executing the function. Its value depends on how the function is called, not where it's defined.

Different Ways this Can Be Bound:

  1. Default Binding (Global/Window):
function greet() {
  console.log(this); // Window object (in browser) or global (in Node.js)
}
greet(); // Called without any context
  1. Implicit Binding (Object Method):
const person = {
  name: 'John',
  greet() {
    console.log(`Hello, I'm ${this.name}`);
  }
};
person.greet(); // this = person object
  1. Explicit Binding (.call(), .apply(), .bind()):
function greet() {
  console.log(`Hello, I'm ${this.name}`);
}

const person1 = { name: 'Alice' };
const person2 = { name: 'Bob' };

// .call() - calls function immediately with specified this
greet.call(person1); // "Hello, I'm Alice"

// .apply() - same as call but takes array of arguments
greet.apply(person2); // "Hello, I'm Bob"

// .bind() - returns new function with bound this
const boundGreet = greet.bind(person1);
boundGreet(); // "Hello, I'm Alice"
  1. New Binding (Constructor):
function Person(name) {
  this.name = name;
  this.greet = function() {
    console.log(`Hello, I'm ${this.name}`);
  };
}

const john = new Person('John');
john.greet(); // this = john instance
  1. Arrow Functions (Lexical Binding):
const person = {
  name: 'John',
  greet: () => {
    console.log(this.name); // this = global object, not person
  },
  greetProperly() {
    const inner = () => {
      console.log(this.name); // this = person (inherited from outer scope)
    };
    inner();
  }
};

Differences Between .call(), .apply(), and .bind():

function introduce(age, city) {
  console.log(`I'm ${this.name}, ${age} years old, from ${city}`);
}

const person = { name: 'Alice' };

// .call() - immediate execution, arguments passed individually
introduce.call(person, 25, 'New York');
// Output: "I'm Alice, 25 years old, from New York"

// .apply() - immediate execution, arguments passed as array
introduce.apply(person, [25, 'New York']);
// Output: "I'm Alice, 25 years old, from New York"

// .bind() - returns new function, can be called later
const boundIntroduce = introduce.bind(person, 25, 'New York');
boundIntroduce(); // Same output as above

// Partial binding
const boundWithAge = introduce.bind(person, 25);
boundWithAge('Boston'); // "I'm Alice, 25 years old, from Boston"

Practical Examples:

// Borrowing methods
const arrayLike = {
  0: 'a',
  1: 'b',
  2: 'c',
  length: 3
};

// Use Array.prototype.slice on array-like object
const realArray = Array.prototype.slice.call(arrayLike);
console.log(realArray); // ['a', 'b', 'c']

// Event handlers
class Button {
  constructor(element) {
    this.element = element;
    this.clickCount = 0;
    
    // Without bind, this would be the DOM element
    this.element.addEventListener('click', this.handleClick.bind(this));
  }
  
  handleClick() {
    this.clickCount++;
    console.log(`Clicked ${this.clickCount} times`);
  }
}

// Currying with bind
function multiply(a, b) {
  return a * b;
}

const double = multiply.bind(null, 2);
console.log(double(5)); // 10

const triple = multiply.bind(null, 3);
console.log(triple(4)); // 12

What's the difference between Promise.all(), Promise.race(), Promise.allSettled(), and Promise.any()? When would you use each?

Answer: These are different Promise combinator methods that handle multiple promises in various ways:

Promise.all():

  • Waits for ALL promises to resolve
  • Fails fast - rejects if ANY promise rejects
  • Returns array of resolved values in same order
  • Use when: You need all operations to succeed
const promises = [
  fetch('/api/users'),
  fetch('/api/posts'),
  fetch('/api/comments')
];

try {
  const [users, posts, comments] = await Promise.all(promises);
  // All requests succeeded
} catch (error) {
  // At least one request failed
  console.error('One or more requests failed:', error);
}

Promise.race():

  • Returns the FIRST promise to settle (resolve or reject)
  • Use when: You want the fastest result, regardless of success/failure
const timeoutPromise = new Promise((_, reject) => 
  setTimeout(() => reject(new Error('Timeout')), 5000)
);

const dataPromise = fetch('/api/slow-endpoint');

try {
  const result = await Promise.race([dataPromise, timeoutPromise]);
  // Got data before timeout
} catch (error) {
  // Either timeout or request failed
  console.error('Request failed or timed out:', error);
}

Promise.allSettled():

  • Waits for ALL promises to settle (resolve or reject)
  • Never rejects - always resolves with array of results
  • Use when: You want to know the outcome of all operations
const promises = [
  fetch('/api/users'),
  fetch('/api/posts'),
  fetch('/api/comments')
];

const results = await Promise.allSettled(promises);

results.forEach((result, index) => {
  if (result.status === 'fulfilled') {
    console.log(`Request ${index} succeeded:`, result.value);
  } else {
    console.log(`Request ${index} failed:`, result.reason);
  }
});

Promise.any():

  • Returns the FIRST promise to resolve (ignores rejections)
  • Only rejects if ALL promises reject
  • Use when: You want the first successful result
const fallbackPromises = [
  fetch('/api/primary'),
  fetch('/api/backup1'),
  fetch('/api/backup2')
];

try {
  const result = await Promise.any(fallbackPromises);
  // Got data from first successful endpoint
} catch (error) {
  // All endpoints failed
  console.error('All endpoints failed:', error);
}

Coding Challenge: Implement a function that retries a failed async operation with exponential backoff.

Answer:

async function retryWithBackoff(
  asyncFn, 
  maxRetries = 3, 
  baseDelay = 1000, 
  maxDelay = 10000
) {
  let lastError;
  
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await asyncFn();
    } catch (error) {
      lastError = error;
      
      if (attempt === maxRetries) {
        throw new Error(`Failed after ${maxRetries + 1} attempts: ${error.message}`);
      }
      
      // Calculate delay with exponential backoff and jitter
      const delay = Math.min(
        baseDelay * Math.pow(2, attempt) + Math.random() * 1000,
        maxDelay
      );
      
      console.log(`Attempt ${attempt + 1} failed, retrying in ${Math.round(delay)}ms...`);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

// Usage example
async function fetchUserData(userId) {
  const response = await fetch(`/api/users/${userId}`);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }
  return response.json();
}

// Retry with custom configuration
try {
  const userData = await retryWithBackoff(
    () => fetchUserData(123),
    5,    // max retries
    1000, // base delay (1s)
    30000 // max delay (30s)
  );
  console.log('User data:', userData);
} catch (error) {
  console.error('Failed to fetch user data:', error);
}

// Advanced version with different retry strategies
class RetryStrategy {
  static async exponentialBackoff(asyncFn, options = {}) {
    const {
      maxRetries = 3,
      baseDelay = 1000,
      maxDelay = 10000,
      jitter = true,
      retryCondition = () => true
    } = options;
    
    let lastError;
    
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        return await asyncFn();
      } catch (error) {
        lastError = error;
        
        if (attempt === maxRetries || !retryCondition(error)) {
          throw error;
        }
        
        const delay = Math.min(
          baseDelay * Math.pow(2, attempt) + (jitter ? Math.random() * 1000 : 0),
          maxDelay
        );
        
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }
  
  static async linearBackoff(asyncFn, options = {}) {
    const { maxRetries = 3, delay = 1000 } = options;
    
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        return await asyncFn();
      } catch (error) {
        if (attempt === maxRetries) throw error;
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }
}