DEV Community

M TOQEER ZIA
M TOQEER ZIA

Posted on

# JavaScript Fundamentals

Thread of Execution and Call Stack

Every line of JavaScript code runs in a specific order. The call stack tracks which function is currently running and what should run next.

Think of it as a stack of plates. You add a plate when you call a function. You remove a plate when the function finishes. The last plate added is the first one removed.

function greet() {
  console.log("Hello");
}

function start() {
  greet();
  console.log("Welcome");
}

start();
Enter fullscreen mode Exit fullscreen mode

When you run this code, the call stack works like this:

  1. start() is called. It goes on the stack.
  2. Inside start(), greet() is called. It goes on the stack.
  3. greet() finishes. It leaves the stack.
  4. start() finishes. It leaves the stack.
  5. The stack is now empty.

The call stack shows the path your code takes. Understanding it helps you debug errors and trace program flow.

Scope and Lexical Scoping

Lexical scoping determines what variables your code accesses based on where you define it, not where you call it. JavaScript searches for variables in the current function's scope. If not found, it moves to the outer function's scope, then to the next outer scope, and finally to global scope. This chain is the scope chain.

Lexical scope is where you can access your variables.

let a = 1;

function outer() {
  let b = 2;

  function inner() {
    let c = 3;
    console.log(a + b + c);
  }

  inner();
}

outer();
Enter fullscreen mode Exit fullscreen mode

In this example, inner() accesses c from its own scope, b from outer()'s scope, and a from global scope. The scope chain helps JavaScript find each variable.

A function defined in a location inherits access to variables from that location, no matter where you call the function.

function outerFunction() {
  let outerVar = "I'm from outerFunction";

  function innerFunction() {
    let innerVar = "I'm from innerFunction";
    console.log(outerVar);
  }

  innerFunction();
}

outerFunction();
Enter fullscreen mode Exit fullscreen mode

innerFunction() accesses outerVar because of lexical scope. It remembers where it was defined.

Dynamic Scoping

Dynamic scoping determines variable values based on where code runs, not where it's defined. Most languages use lexical scoping. JavaScript uses lexical scoping, but this concept matters when learning about other languages or advanced patterns.

var x = 1;

function printX() {
  console.log(x);
}

function foo() {
  var x = 2;
  printX();
}

foo();
Enter fullscreen mode Exit fullscreen mode

With lexical scoping (JavaScript's actual behavior), this logs 1 because printX() was defined in global scope where x = 1.

Closures

A closure forms when a function accesses variables from its outer scope, even after that outer function finishes running.

function outerFunction() {
  let name = "Ali";

  return function innerFunction() {
    console.log(name);
  };
}

let myFunc = outerFunction();
myFunc();
Enter fullscreen mode Exit fullscreen mode

Even though outerFunction() has finished, innerFunction() still remembers the name variable. This is a closure.

Closures give you private variables and enable powerful programming patterns. Every function in JavaScript creates a closure.

How Closures Cause Memory Leaks

A memory leak occurs when your app holds data it no longer needs. The garbage collector cannot clean up memory when a closure references large objects unnecessarily.

function createLeak() {
  const largeData = new Array(1000000).fill('data');

  return function leakyFunction() {
    console.log(largeData[0]);
  };
}

const leaky = createLeak();
Enter fullscreen mode Exit fullscreen mode

This closure holds the entire largeData array in memory even though leakyFunction() only uses one value.

Fix Memory Leaks

Extract only what you need before creating the closure.

function createFixed() {
  const largeData = new Array(1000000).fill('data');
  const neededValue = largeData[0];

  return function fixedFunction() {
    console.log(neededValue);
  };
}

const fixed = createFixed();
Enter fullscreen mode Exit fullscreen mode

Now the closure only keeps the small value. The garbage collector cleans up the rest.

Callback Hell

Callback hell happens when you nest multiple asynchronous callbacks, creating deep pyramids of code. Each asynchronous operation depends on the previous one, forcing nested callbacks within callbacks.

getUser(function (user) {
  getPosts(user.id, function (posts) {
    getComments(posts[0].id, function (comments) {
      sendNotification(comments[0], function (response) {
        console.log("Notification sent!");
      });
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

This pattern makes code hard to read, maintain, and debug.

Why Callback Hell Occurs

Callback hell results from chaining multiple asynchronous tasks sequentially. When one task's completion triggers the next, the code nests deeper with each operation. This nesting structure reduces readability and complicates error handling.

Escape Callback Hell

Use promises or async/await instead of nested callbacks.

async function handleTasks() {
  try {
    const user = await getUser();
    const posts = await getPosts(user.id);
    const comments = await getComments(posts[0].id);
    await sendNotification(comments[0]);
    console.log("Notification sent!");
  } catch (err) {
    console.error(err);
  }
}

handleTasks();
Enter fullscreen mode Exit fullscreen mode

This approach is cleaner and easier to follow.

Event Loop

The event loop manages when code runs. It coordinates the call stack, web APIs, and task queues.

Here's how it works:

Call Stack stores your JavaScript code as it runs, line by line. Code executes from top to bottom. Once a task finishes, it leaves the stack.

Web APIs are browser functions like setTimeout, fetch, and DOM methods. They handle long tasks without blocking your code. When they finish, they send results to queues.

Priority Queue (Microtask Queue) stores high priority tasks, mostly from promises. After the call stack empties, JavaScript runs priority queue tasks first.

Callback Queue (Task Queue) stores lower priority tasks like setTimeout and click events. The event loop processes this queue after the priority queue.

Event Loop is the manager. It constantly checks if the call stack is empty. If yes, it moves tasks from the priority queue to the call stack. Then it moves tasks from the callback queue.

How It Works in Action

  1. Your code runs on the call stack.
  2. setTimeout, fetch, or Promise are handled by web APIs.
  3. When web APIs finish, results go to queues.
  4. When the call stack empties, the event loop moves tasks to the stack.

Promises (priority queue) always run before setTimeout or click events (callback queue). This ordering matters for your code's behavior.

Debouncing

Debouncing delays function execution until after a user stops triggering events for a set time. It prevents excessive function calls from rapid events like typing or scrolling.

Use debouncing to wait until the user stops typing, then act on the final input.

function debounce(func, delay) {
  let timer;

  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => func.apply(this, args), delay);
  };
}

const searchInput = document.getElementById("searchInput");
const output = document.getElementById("output");

function handleSearch(event) {
  const value = event.target.value;
  console.log("Searching for:", value);
  output.textContent = `Searching for: ${value}`;
}

searchInput.addEventListener("input", debounce(handleSearch, 500));
Enter fullscreen mode Exit fullscreen mode

Each keystroke cancels the previous timer and starts a new one. When typing stops for 500ms, the function runs.

Variable Hoisting

JavaScript moves declarations to the top of their scope before code runs. This is hoisting.

console.log(a);
var a = 5;
Enter fullscreen mode Exit fullscreen mode

This logs undefined, not an error. JavaScript hoists var a but not its assignment.

console.log(b);
let b = 10;
Enter fullscreen mode Exit fullscreen mode

This throws an error. let and const are hoisted but not initialized. They exist in a temporal dead zone until the line declaring them runs.

var vs let vs const

var is function scoped and can be redeclared and reassigned.

var x = 1;
x = 10;
var x = 20;
console.log(x);
Enter fullscreen mode Exit fullscreen mode

let is block scoped. It prevents redeclaration but allows reassignment.

let y = 2;
y = 20;
console.log(y);
Enter fullscreen mode Exit fullscreen mode

const is block scoped and prevents reassignment. Objects and arrays assigned to const have their properties modified, but the variable reference stays the same.

const z = 3;
z = 30;
Enter fullscreen mode Exit fullscreen mode

This throws an error.

Use const by default. Use let when you need to reassign. Avoid var in modern JavaScript.

Null vs Undefined

null is a value you assign when you want nothing. It represents intentional emptiness.

let a = null;
console.log(a);
Enter fullscreen mode Exit fullscreen mode

undefined is what JavaScript assigns when you declare a variable without a value or when a function returns nothing.

let b;
console.log(b);
Enter fullscreen mode Exit fullscreen mode

The difference matters. null means you chose emptiness. undefined means something wasn't set.

NaN (Not a Number)

NaN is a special numeric value representing an invalid operation result.

let x = "hello" * 2;
console.log(x);
console.log(typeof x);
Enter fullscreen mode Exit fullscreen mode

This logs NaN and number. NaN is a number type, which confuses many developers.

Type Conversion

JavaScript converts values between types. Understanding conversion prevents bugs.

let str = "123";
let num = Number(str);
console.log(typeof num);

let invalid = Number("abc");
console.log(invalid);
Enter fullscreen mode Exit fullscreen mode

Number() converts strings to numbers. Invalid conversions produce NaN.

typeof Operator

typeof returns the type of a value as a string.

console.log(typeof 5);
console.log(typeof "hi");
console.log(typeof null);
Enter fullscreen mode Exit fullscreen mode

This logs number, string, and object. Checking typeof null returns object, a quirk in JavaScript.

Primitive and Non-Primitive Data Types

Primitives are immutable basic values. They include Number, String, Boolean, Undefined, Null, Symbol, and BigInt.

let name = "John";
name = "Jane";
Enter fullscreen mode Exit fullscreen mode

Non-primitives are objects that store references. They include Object, Array, and Function.

let fruits = ["apple", "banana", "mango"];
fruits.forEach(fruit => console.log(fruit));
Enter fullscreen mode Exit fullscreen mode

Primitives are copied by value. Objects are copied by reference.

First-Class Functions

JavaScript treats functions as first-class citizens. You can assign functions to variables, pass them as arguments, and return them from other functions.

const greet = function(name) {
  console.log(`Hello, ${name}!`);
};

greet("Alice");
Enter fullscreen mode Exit fullscreen mode

This ability enables higher-order functions, closures, and callbacks.

Higher-Order Functions

A higher-order function takes another function as an argument or returns a function.

function greet(name) {
  return function(message) {
    console.log(message + ", " + name);
  };
}

const greetJohn = greet("John");
greetJohn("Hello");
Enter fullscreen mode Exit fullscreen mode

Higher-order functions build powerful abstractions. map(), filter(), and reduce() are higher-order functions.

IIFE (Immediately Invoked Function Expression)

An IIFE is a function that runs immediately after definition.

(function() {
  console.log("Runs right away!");
})();
Enter fullscreen mode Exit fullscreen mode

IIFEs create a scope for variables, preventing them from polluting global scope.

Currying

Currying transforms a function with multiple arguments into nested single-argument functions.

function add(a) {
  return function(b) {
    return a + b;
  };
}

console.log(add(2)(3));
Enter fullscreen mode Exit fullscreen mode

Curried functions enable partial application, where you fix some arguments and apply others later.

Array Methods

map()

map() transforms each element and returns a new array.

let nums = [1, 2, 3];
let doubled = nums.map(n => n * 2);
console.log(doubled);
Enter fullscreen mode Exit fullscreen mode

filter()

filter() keeps elements that match a condition.

let nums = [1, 2, 3];
let evens = nums.filter(n => n % 2 === 0);
console.log(evens);
Enter fullscreen mode Exit fullscreen mode

forEach()

forEach() runs a function on each element. It returns nothing.

let nums = [1, 2, 3];
nums.forEach((num, i) => nums[i] = num + 1);
console.log(nums);
Enter fullscreen mode Exit fullscreen mode

Unlike map(), forEach() mutates the original array if you change elements inside.

find()

find() returns the first element matching a condition.

let nums = [1, 2, 3];
let firstEven = nums.find(num => num % 2 === 0);
console.log(firstEven);
Enter fullscreen mode Exit fullscreen mode

some()

some() checks if at least one element matches a condition.

let nums = [1, 3, 5, 6];
console.log(nums.some(num => num % 2 === 0));
Enter fullscreen mode Exit fullscreen mode

every()

every() checks if all elements match a condition.

let nums = [2, 4, 6];
console.log(nums.every(num => num % 2 === 0));
Enter fullscreen mode Exit fullscreen mode

reduce()

reduce() transforms an array into a single value.

let sum = [1, 2, 3].reduce((total, num) => total + num, 0);
console.log(sum);
Enter fullscreen mode Exit fullscreen mode

slice() vs splice()

slice() returns a shallow copy of part of an array without changing the original.

let nums = [1, 2, 3, 4];
console.log(nums.slice(1, 3));
console.log(nums);
Enter fullscreen mode Exit fullscreen mode

splice() adds or removes elements, changing the original array.

let nums = [1, 2, 3, 4];
nums.splice(1, 2, 99);
console.log(nums);
Enter fullscreen mode Exit fullscreen mode

call(), apply(), and bind()

These three methods control the this context of a function.

call()

call() invokes the function immediately with a specified this value and comma-separated arguments.

function greet() {
  console.log("Hello " + this.name);
}

const person = { name: "Alice" };
greet.call(person);
Enter fullscreen mode Exit fullscreen mode

apply()

apply() works like call() but takes arguments as an array.

greet.apply(person);
const nums = [1, 2, 3];
Math.max.apply(null, nums);
Enter fullscreen mode Exit fullscreen mode

bind()

bind() returns a new function with a bound this value without invoking it.

const boundGreet = greet.bind(person);
boundGreet();
Enter fullscreen mode Exit fullscreen mode

Use bind() to save a function for later with a fixed context.

Event Bubbling and Capturing

Events flow through the DOM in two phases. Bubbling goes from child to parent. Capturing goes from parent to child.

document.getElementById("parent").addEventListener("click", () => {
  console.log("Parent clicked");
});

document.getElementById("child").addEventListener("click", () => {
  console.log("Child clicked");
});
Enter fullscreen mode Exit fullscreen mode

When you click the child, the child event fires first, then the parent event. This is bubbling.

Add a third argument to listen during capturing instead:

document.getElementById("parent").addEventListener("click", () => {
  console.log("Parent clicked");
}, true);
Enter fullscreen mode Exit fullscreen mode

Event Delegation

Event delegation uses a single listener on a parent element to handle events from many children. It relies on event bubbling.

document.getElementById("list").addEventListener("click", (e) => {
  if (e.target.tagName === "LI") {
    alert(e.target.textContent);
  }
});
Enter fullscreen mode Exit fullscreen mode

This approach reduces memory use and handles dynamically added elements automatically.

Promises

A promise represents a value that may not exist yet but will eventually. It has three states: pending, resolved (fulfilled), or rejected.

const newPromise = new Promise((resolve, reject) => {
  let x = 0;
  if (x == 0) {
    resolve("OK");
  } else {
    reject("Error");
  }
});

fetch("https://jsonplaceholder.typicode.com/posts/1")
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => console.error(err));
Enter fullscreen mode Exit fullscreen mode

Promises replace callbacks for handling asynchronous operations.

async/await

async/await makes asynchronous code look synchronous and easier to read.

async function getData() {
  try {
    const res = await fetch("https://jsonplaceholder.typicode.com/posts/1");
    const data = await res.json();
    console.log(data);
  } catch (err) {
    console.error(err);
  }
}

getData();
Enter fullscreen mode Exit fullscreen mode

async functions return promises. await pauses execution until a promise resolves. async/await is built on promises.

Shallow vs Deep Copy

A shallow copy duplicates the top level. A deep copy duplicates all levels.

let obj = { name: "John", address: { city: "NY" } };

let shallow = { ...obj };
shallow.address.city = "LA";
console.log(obj.address.city);
Enter fullscreen mode Exit fullscreen mode

The nested object is shared, so changing it in the copy affects the original.

let deep = JSON.parse(JSON.stringify(obj));
deep.address.city = "SF";
console.log(obj.address.city);
Enter fullscreen mode Exit fullscreen mode

Deep copies are independent.

setTimeout and setInterval

setTimeout runs a function once after a delay.

setTimeout(() => {
  console.log("Runs once after 2 seconds");
}, 2000);
Enter fullscreen mode Exit fullscreen mode

setInterval runs a function repeatedly at fixed intervals.

let count = 0;
let timer = setInterval(() => {
  console.log("Interval: " + count);
  if (++count === 3) clearInterval(timer);
}, 1000);
Enter fullscreen mode Exit fullscreen mode

Generator Functions

A generator function pauses and resumes execution. Use yield to pause and next() to resume.

function* counter() {
  let i = 0;
  while (true) {
    yield i++;
  }
}

const gen = counter();
console.log(gen.next().value);
console.log(gen.next().value);
Enter fullscreen mode Exit fullscreen mode

Generators create iterators and handle complex state management.

Set and WeakSet

A Set stores unique values of any type. It prevents duplicate entries.

const set = new Set();
set.add(1);
set.add([1, 2, 3, 4, 5, 6, 7]);
set.add(2);
console.log(set.has(1));
console.log(set.delete(7));

for (let i of set) {
  console.log(i);
}
Enter fullscreen mode Exit fullscreen mode

A WeakSet stores only objects and allows garbage collection. You cannot iterate a WeakSet.

const weakSet = new WeakSet();
let obj = {
  message: "Hi",
  sendMessage: true,
};
weakSet.add(obj);
console.log(weakSet);
Enter fullscreen mode Exit fullscreen mode

Use WeakSet for tracking objects without preventing garbage collection.

Enumerable Properties

An enumerable property appears when iterating with for...in, Object.keys(), or JSON.stringify().

const obj = { name: "Alice" };
console.log(Object.keys(obj));

for (let key in obj) {
  console.log(key);
}
Enter fullscreen mode Exit fullscreen mode

A non-enumerable property is hidden from these iterations.

const obj = {};

Object.defineProperty(obj, 'secret', {
  value: 'hidden value',
  enumerable: false
});

console.log(obj.secret);
console.log(Object.keys(obj));
Enter fullscreen mode Exit fullscreen mode

Non-enumerable properties still exist and are accessible by name.

Symbols

A Symbol is a unique and immutable value. No two symbols are equal, even with the same description.

const sym1 = Symbol();
const sym2 = Symbol("foo");
const sym3 = Symbol("foo");
Enter fullscreen mode Exit fullscreen mode

sym2 and sym3 look identical but are different symbols.

Use Symbol.for() to create shared symbols:

const shared1 = Symbol.for("key");
const shared2 = Symbol.for("key");
console.log(shared1 === shared2);
Enter fullscreen mode Exit fullscreen mode

Symbol.for() always returns the same symbol for the same key. This is useful for libraries that need consistent symbols across code.

HTTP-Only and SameSite Cookies

HTTP-only cookies are inaccessible to client-side JavaScript. This prevents Cross-Site Scripting (XSS) attacks where malicious scripts steal cookie data.

SameSite controls how cookies are sent in cross-site requests. It prevents Cross-Site Request Forgery (CSRF) attacks.

res.cookie('sessionId', 'abc123', {
  httpOnly: true,
  secure: true,
  sameSite: 'strict'
});
Enter fullscreen mode Exit fullscreen mode

Set httpOnly to prevent scripts from reading the cookie. Set secure to send it only over HTTPS. Set sameSite to strict, lax, or none to control cross-site sending.

localStorage vs sessionStorage

Both store strings on the browser. They differ in persistence and scope.

localStorage persists until you clear it. It is shared across all tabs and windows.

sessionStorage clears when the tab or window closes. It is specific to one tab.

Both have a storage limit of about 5MB. Both are synchronous, which can block the main thread with large data.

Observer Pattern

The observer pattern lets one object notify many subscribers when it changes.

class Observer {
  constructor() {
    this.subscribers = [];
  }

  subscribe(fn) {
    this.subscribers.push(fn);
  }

  notify(data) {
    this.subscribers.forEach(fn => fn(data));
  }
}

const obs = new Observer();
obs.subscribe((data) => console.log("Subscriber 1:", data));
obs.subscribe((data) => console.log("Subscriber 2:", data));
obs.notify("Hello Observers!");
Enter fullscreen mode Exit fullscreen mode

This pattern powers event systems and reactive frameworks.

Cookies vs Sessions

Cookies are small text files stored on the user's browser. Sessions store data on the server.

Cookies are sent with every request, making them convenient for small amounts of data. Sessions are secure for sensitive data because the server keeps the data private.

Sessions use a session ID cookie to link the browser to server-side data. The browser sends the ID with each request. The server looks up the session data using that ID.

Use cookies for non-sensitive client preferences. Use sessions for user authentication and sensitive information.

Primitive Type Wrapping

JavaScript temporarily wraps primitives in objects when you access properties on them.

let x = 10;
x.prop = 'test';
console.log(x.prop);
Enter fullscreen mode Exit fullscreen mode

When you assign x.prop = 'test', JavaScript wraps the number 10 in a Number object, adds the property, then discards the wrapper.

When you read x.prop, JavaScript creates a new wrapper, looks for the property (which doesn't exist), and returns undefined.

Primitives are not true objects, so property assignment does not persist.

Top comments (0)