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();
When you run this code, the call stack works like this:
-
start()is called. It goes on the stack. - Inside
start(),greet()is called. It goes on the stack. -
greet()finishes. It leaves the stack. -
start()finishes. It leaves the stack. - 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();
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();
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();
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();
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();
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();
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!");
});
});
});
});
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();
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
- Your code runs on the call stack.
-
setTimeout,fetch, orPromiseare handled by web APIs. - When web APIs finish, results go to queues.
- 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));
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;
This logs undefined, not an error. JavaScript hoists var a but not its assignment.
console.log(b);
let b = 10;
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);
let is block scoped. It prevents redeclaration but allows reassignment.
let y = 2;
y = 20;
console.log(y);
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;
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);
undefined is what JavaScript assigns when you declare a variable without a value or when a function returns nothing.
let b;
console.log(b);
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);
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);
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);
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";
Non-primitives are objects that store references. They include Object, Array, and Function.
let fruits = ["apple", "banana", "mango"];
fruits.forEach(fruit => console.log(fruit));
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");
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");
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!");
})();
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));
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);
filter()
filter() keeps elements that match a condition.
let nums = [1, 2, 3];
let evens = nums.filter(n => n % 2 === 0);
console.log(evens);
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);
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);
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));
every()
every() checks if all elements match a condition.
let nums = [2, 4, 6];
console.log(nums.every(num => num % 2 === 0));
reduce()
reduce() transforms an array into a single value.
let sum = [1, 2, 3].reduce((total, num) => total + num, 0);
console.log(sum);
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);
splice() adds or removes elements, changing the original array.
let nums = [1, 2, 3, 4];
nums.splice(1, 2, 99);
console.log(nums);
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);
apply()
apply() works like call() but takes arguments as an array.
greet.apply(person);
const nums = [1, 2, 3];
Math.max.apply(null, nums);
bind()
bind() returns a new function with a bound this value without invoking it.
const boundGreet = greet.bind(person);
boundGreet();
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");
});
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);
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);
}
});
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));
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();
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);
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);
Deep copies are independent.
setTimeout and setInterval
setTimeout runs a function once after a delay.
setTimeout(() => {
console.log("Runs once after 2 seconds");
}, 2000);
setInterval runs a function repeatedly at fixed intervals.
let count = 0;
let timer = setInterval(() => {
console.log("Interval: " + count);
if (++count === 3) clearInterval(timer);
}, 1000);
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);
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);
}
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);
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);
}
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));
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");
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);
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'
});
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!");
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);
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)