My Logs.

Building. Thinking. Writing.

Go back

Understanding JavaScript from the Inside Out

14 min read Edit page

Table of Contents

Open Table of Contents

Introduction

In this blog, we’ll understand how JavaScript actually works and how it’s different from other programming languages.
The goal is simple: if you truly understand the concepts, you won’t need to memorize random JS gotchas. You’ll naturally understand why something behaves the way it does.
We won’t focus on listing weird edge cases. Instead, we’ll focus on understanding the language deeply — and the gotchas will become a byproduct of that understanding.


A Brief History of JavaScript

JavaScript was created to make web pages alive.
Initially, it wasn’t considered a full-fledged programming language but a scripting language — something that could run automatically when a webpage loads alongside HTML and CSS. It didn’t require compilation or special setup; it just needed a runtime (like a browser or Node.js).
Despite the name, JavaScript has nothing to do with Java. It was originally called LiveScript, but was renamed to JavaScript for marketing reasons because Java was popular at the time.
Over time, JavaScript evolved into a powerful language with its own official specification called ECMAScript.


What is JavaScript?

At first glance, the definition of JavaScript might feel overwhelming, but we’ll break it down step by step.

JavaScript is a high-level, dynamically typed, prototype-based, multi-paradigm programming language with first-class functions, primarily used to build interactive and dynamic web applications.

Let’s understand what this actually means:

  • High-level — You don’t need to worry about low-level details like memory management. JavaScript abstracts that away so you can focus on logic.
  • Interpreted (JIT under the hood) — JavaScript runs directly in a runtime like a browser or Node.js. Modern engines use Just-In-Time (JIT) compilation to optimize performance during execution.
  • Dynamically typed — Variables don’t have fixed types. A variable can hold any kind of value, and its type can change at runtime.
  • Prototype-based — Instead of traditional class-based inheritance, JavaScript uses prototypes. Every object has a hidden [[Prototype]] link to another object. If a property isn’t found, JavaScript looks up this chain. Behavior is shared through linking, not copying.
  • Multi-paradigm — JavaScript supports multiple styles: procedural, object-oriented, and functional. You’re not forced into one way of thinking.
  • First-class functions — Functions are just values. You can store them in variables, pass them around, and return them from other functions — which makes JavaScript very flexible.

If you understand these ideas deeply, most “weird” JavaScript behaviors will start making sense automatically.


Callbacks & Higher Order Functions (HOFs)

  • A callback is a function passed as an argument to another function, which can be called later. This allows for asynchronous programming and event handling.
  • A Higher Order Function (HOF) is a function that takes another function as an argument or returns a function. This is a powerful concept that enables functional programming patterns in JavaScript.
// HOF
function calculate(a, b, operation) {
  return operation(a, b);
}

// Callback fn.
function add(x, y) {
  return x + y;
}

calculate(2, 3, add);

How JavaScript Executes Code

Execution Context

When JavaScript runs your code, it creates something called an Execution Context (EC).
The very first time your code runs, a Global Execution Context (GEC) is created. All top-level code runs inside this context. When execution finishes, this context is cleared.
Execution contexts can be nested when you call functions. Each time a function is called, a new Function Execution Context (FEC) is created for that function. When the function finishes executing, its context is destroyed and control returns to the previous context.
So what exactly is an Execution Context?
Think of it as a box or environment created whenever code runs (globally or inside a function). It stores:

  • Variables
  • Function declarations
  • The current state of execution

This is managed by the Call Stack.


Scope and Access

JavaScript follows lexical scoping:

  • A function can access variables from its own scope and its outer (parent) scope
  • But outer scopes cannot access variables inside inner scopes

JavaScript looks for variables upward in the scope chain, never downward.


How Execution Happens

Execution happens in two phases:

1. Memory Phase (Creation Phase)

Before running your code, JavaScript scans it and sets up memory.

  • Variables declared with var are initialized with undefined. That’s why accessing them before declaration gives undefined. This behavior is called hoisting.
  • Variables declared with let and const are also hoisted, but not initialized. They remain in a Temporal Dead Zone (TDZ) — a phase where the variable exists but cannot be accessed until it is declared.
  • Function declarations (function sayHi() {}) are fully stored in memory along with their body. That’s why they can be called before their definition.
  • Function expressions and arrow functions behave like variables. If declared with var, they are initialized as undefined first, and assigned later.

2. Execution Phase

Now JavaScript starts executing code line by line.

  • Variables get their actual values
  • Functions are invoked
  • The execution state keeps updating as code runs

Hoisting is JavaScript’s behavior of registering variable and function declarations in memory before execution, allowing them to be accessed earlier in code (with different behaviors for var, let, const, and functions).


Call Stack

The Call Stack is a data structure that keeps track of execution contexts.

  • When a function is called, a new execution context is created and pushed onto the stack.
  • When a function finishes, its context is popped off the stack and control returns to the previous context.
  • Since JavaScript is single-threaded, it can execute only one thing at a time — the call stack manages this flow.
  • If the stack grows too deep (e.g., due to infinite recursion), it results in a Stack Overflow error.

Garbage Collection

JavaScript automatically manages memory using Garbage Collection. When variables or objects are no longer reachable (no references point to them), the engine frees that memory.
JavaScript uses an algorithm called Mark-and-Sweep:

  1. Mark phase — The garbage collector starts from root references (like the global object) and marks all reachable objects.
  2. Sweep phase — It removes all unmarked (unreachable) objects, freeing up memory.

As long as something is reachable, it will not be garbage collected — even if you’re not actively using it.


Closure & Scope Chain

When a function is created, it stores a hidden reference [[Environment]] to the Lexical Environment (LE) where it was defined.
A Lexical Environment is a structure that:

  • Stores variables and functions of the current scope
  • Holds a reference to its outer lexical environment

How Closure Works

function outer() {
  let count = 0;

  function inner() {
    count++;
    console.log(count);
  }

  return inner;
}

const counter = outer();
counter(); // 1
counter(); // 2

Here’s what happens step by step:

  1. When outer() runs, it creates an execution context and a lexical environment containing count.
  2. inner is defined inside outer, so it captures a reference to that lexical environment via [[Environment]].
  3. After outer finishes, its execution context is removed from the call stack — but its lexical environment is not garbage collected, because inner still holds a reference to it.
  4. When counter() is called:
    • It first looks for count in its own scope → not found
    • Then looks in the outer lexical environment → found
    • This is why count persists across multiple calls.

Scope Chain

When JavaScript tries to access a variable, it:

  1. Looks in the current scope
  2. If not found, looks in the outer scope
  3. Continues outward up the chain

This is called the Scope Chain. JavaScript always searches outward (upward), never inward.

A closure is not just a function — it is a function paired with its preserved lexical environment.

Use closures when you want a function to maintain its own persistent state across multiple calls.


Asynchronous JavaScript & the Event Loop

Before understanding asynchronous code, it’s important to know that JavaScript itself does not provide features like timers, network requests, or DOM APIs. These are provided by the runtime environment (browser or Node.js).
For example:

  • setTimeout → provided by browser / Node.js
  • fetch → provided by browser / Node.js (modern)
  • document → provided by browser only

JavaScript itself provides only the core language (variables, functions, objects, etc.).

JavaScript + Runtime = What you actually use

How Async Code Actually Works

When you use functions like setTimeout or fetch, JavaScript delegates the work to the runtime. Once the work is complete, the callback is placed into a queue, and the Event Loop decides when it should run.

  • Callback Queue (Task Queue) — Stores callbacks from APIs like setTimeout, setInterval, etc. Works as FIFO (First In, First Out).
  • Event Loop — Continuously checks:
    • Is the call stack empty?
    • If yes, take a task from the queue and push it to the stack.

Example 1 — setTimeout & Blocking

console.log("Start");

setTimeout(() => {
  console.log("Inside Timeout 1");
}, 1000);

function heavyTask() {
  // blocks for ~1 second
}
heavyTask();

setTimeout(() => {
  console.log("Inside Timeout 2");
}, 1000);

console.log("End");

Execution Flow

  1. "Start" is printed.
  2. First setTimeout is delegated to the runtime → timer starts.
  3. heavyTask() runs and blocks the call stack for ~1 second.
  4. During this time:
    • Timer 1 finishes → its callback goes to the Callback Queue.
    • But it cannot execute because the stack is busy.
  5. After heavyTask finishes:
    • Second setTimeout is registered → timer starts now.
    • "End" is printed.
  6. Call stack becomes empty.
  7. Event Loop pushes Timeout 1’s callback → executes it.
  8. Timeout 2 finishes → pushed → executed.

Output

Start
End
Inside Timeout 1
Inside Timeout 2

Microtask Queue (Higher Priority)

  • Stores Promise callbacks (.then, .catch, .finally) and queueMicrotask.
  • Has higher priority than the Callback Queue.

After the call stack becomes empty, the Event Loop executes all microtasks first, then takes one task from the Callback Queue.

Example 2 — fetch & Microtasks

console.log("Start");

function display(data) {
  console.log(data);
}

setTimeout(() => {
  console.log("Inside Timeout 1");
}, 1000);

const futureData = fetch('https://aayushmaan.me/contact');

setTimeout(() => {
  console.log("Inside Timeout 2");
}, 1000);

futureData.then(display);

console.log("End");

Execution Flow

  1. "Start" is printed.
  2. First setTimeout → delegated to runtime.
  3. fetch → delegated to runtime → returns a pending Promise, stored in futureData.
  4. Second setTimeout → delegated to runtime.
  5. .then(display) registers display as the callback for when the Promise resolves.
  6. "End" is printed.
  7. Call stack becomes empty.

Meanwhile, in the background:

  • Fetch completes → Promise resolves → .then callback (display) goes to the Microtask Queue.

Event Loop:

  • Runs all microtasks firstdisplay executes with the response data.
  • Then processes Callback Queue → Timeout 1 → Timeout 2.

Output

Start
End
<ResponseData>
Inside Timeout 1
Inside Timeout 2

Async/Await (Syntax Sugar Over Promises)

async/await does not change how JavaScript works internally. It is just a cleaner way to write Promise-based code.

async function getData() {
  const res = await fetch("url");
  const data = await res.json();
  console.log(data);
}

Under the Hood — Step by Step

  1. getData() is called → its execution context is pushed onto the call stack.
  2. fetch("url") is called → delegated to the runtime → returns a pending Promise.
  3. await is hit → the function is paused and its execution context is suspended (temporarily removed from the call stack).
  4. The call stack is now free — other synchronous code can run normally.
  5. When fetch completes → the Promise resolves → the rest of getData (after the first await) is placed in the Microtask Queue.
  6. The Event Loop picks it up → getData resumes from where it paused → res now holds the response.
  7. await res.json() is hit → res.json() returns another Promise → the function pauses again.
  8. When res.json() resolves → placed in the Microtask Queue again → function resumes → data holds the parsed JSON.
  9. console.log(data) executes.

Every await is essentially a .then() — the function pauses, frees the call stack, and resumes via the Microtask Queue once the Promise settles.


Type Coercion

JavaScript is a loosely (dynamically) typed language, which means it can automatically convert values from one type to another. This behavior is called Type Coercion.

console.log("5" + 2); // "52"

Here, JavaScript converts 2 into "2" and performs string concatenation.

1. Implicit Coercion (Automatic)

JavaScript does it for you:

"5" + 2     // "52"
"5" - 2     // 3
true + 1    // 2
false + 1   // 1
  • + prefers string concatenation if one operand is a string
  • -, *, / prefer number conversion

2. Explicit Coercion (Manual)

Number("5")     // 5
String(10)      // "10"
Boolean(0)      // false

Boolean Conversion

Falsy values in JavaScript:

Boolean("")         // false
Boolean(0)          // false
Boolean(null)       // false
Boolean(undefined)  // false
Boolean(NaN)        // false

Everything else is true.

== vs ===

5 == "5"   // true
5 === "5"  // false
  • == → allows type coercion before comparing
  • === → strict comparison, no coercion

Prefer === unless you clearly understand the coercion rules.

Classic Confusing Examples

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

This happens because JavaScript performs multiple internal conversions to make types comparable.


Stack vs Heap

Stack

  • Fast, follows LIFO (Last In, First Out)
  • Stores execution contexts and primitive values
let a = 10;
let b = a;

Here, a and b are separate copies — changing one does not affect the other.

Heap

  • Stores objects, arrays, and functions
  • Variables store references, not actual values
let obj1 = { name: "Aayush" };
let obj2 = obj1;

Both obj1 and obj2 point to the same object in memory.

console.log({} === {}); // false
  • For primitives, === compares values.
  • For objects, === compares references (memory locations).

Object to Primitive Conversion (ToPrimitive)

Sometimes JavaScript needs to convert objects into primitive values. This is called ToPrimitive.

let x = new Date();
let y = new Date();

console.log(x - y); // 0

Even though x and y are objects, subtraction works because JavaScript converts them into numbers (timestamps).

How It Works

JavaScript tries to convert objects using (in order):

  1. Symbol.toPrimitive (if defined)
  2. valueOf()
  3. toString()

Custom Conversion

let obj = {
  [Symbol.toPrimitive](hint) {
    if (hint === "number") return 10;
    if (hint === "string") return "hello";
    return 0;
  }
};

console.log(+obj);        // 10
console.log(String(obj)); // "hello"

Symbols

A Symbol is a unique and immutable primitive value, often used as object keys.

let id  = Symbol("id");
let id1 = Symbol("id");

let obj = {
  [id]:  123,
  [id1]: 456,
};

console.log(obj[id]);  // 123
console.log(obj[id1]); // 456

Symbol("id") === Symbol("id"); // false

Even if two symbols have the same description, they are always unique.

Why Symbols Matter

With normal string keys, values can be accidentally overwritten:

let obj = {};
obj["id"] = 1;
obj["id"] = 2; // overwritten ⚠️

With symbols, keys never collide — even across third-party libraries or shared objects.


Prototype & Prototype Chain

JavaScript is a prototype-based language, which means objects can inherit properties and methods from other objects.
Unlike class-based languages (like Java or C++), JavaScript does not rely on copying behavior. Instead, it uses linking between objects.

What is a Prototype?

Every JavaScript object has a hidden internal property called [[Prototype]].
This [[Prototype]] points to another object.

let obj = {};

Here, obj internally looks like:

obj.[[Prototype]] → Object.prototype

You can access it using:

console.log(obj.__proto__); // not recommended in production

or the standard way:

Object.getPrototypeOf(obj);

Prototype Chain

If a property is not found on an object, JavaScript looks for it in its prototype. If not found there, it looks in the prototype’s prototype, and so on. This chain continues until it reaches null.
This is called the Prototype Chain.

let obj = {};

console.log(obj.toString());
  • toString is not in obj
  • It is found in Object.prototype

So the lookup goes:

obj → Object.prototype → null

How Objects Inherit

You can manually create prototype links using Object.create:

let parent = {
  greet() {
    console.log("Hello");
  }
};

let child = Object.create(parent);

child.greet(); // Hello
  • child does not have greet
  • It gets it from parent via the prototype chain

Functions and Prototypes

Functions in JavaScript are special. Every function has a property called prototype.

function Person(name) {
  this.name = name;
}

Person.prototype.sayHi = function () {
  console.log("Hi " + this.name);
};

let p1 = new Person("Aayush");
p1.sayHi(); // Hi Aayush

What Happens with new?

When you use new:

let p1 = new Person("Aayush");

JavaScript does:

  1. Creates a new empty object
  2. Sets its [[Prototype]] to Person.prototype
  3. Calls Person with this pointing to that object
  4. Returns the object

So the chain becomes:

p1 → Person.prototype → Object.prototype → null

Important Difference

  • __proto__ → exists on all objects (accessor for [[Prototype]])
  • prototype → exists only on functions (used when creating objects with new)

Method Sharing (Why Prototypes Exist)

Without prototypes:

function Person(name) {
  this.name = name;
  this.sayHi = function () {
    console.log("Hi " + this.name);
  };
}

Every object gets its own copy of sayHi — wasteful in memory.
With prototypes:

Person.prototype.sayHi = function () {
  console.log("Hi " + this.name);
};

Now all instances share the same method.

Classes (Just Syntax Sugar)

class Person {
  constructor(name) {
    this.name = name;
  }

  sayHi() {
    console.log("Hi " + this.name);
  }
}

This is internally the same as prototype-based code. Classes in JavaScript are just a cleaner syntax over prototypes.

hasOwnProperty and Property Lookup

When you access a property, JavaScript looks:

  1. On the object itself
  2. Then up the prototype chain

But sometimes you want to know: does this object actually have this property, or is it coming from the prototype?
That’s where hasOwnProperty comes in.

let parent = {
  greet() {
    console.log("Hello");
  }
};

let child = Object.create(parent);
child.name = "Aayush";

console.log(child.name);   // "Aayush"
console.log(child.greet);  // function

console.log(child.hasOwnProperty("name"));  // true
console.log(child.hasOwnProperty("greet")); // false
  • name → exists directly on child
  • greet → comes from the prototype

Why This Matters

In loops like for...in, JavaScript iterates over both own and inherited properties:

for (let key in child) {
  console.log(key); // includes "greet" from prototype
}

To only iterate over own properties:

for (let key in child) {
  if (child.hasOwnProperty(key)) {
    console.log(key); // only "name"
  }
}

Modern Alternative

Instead of hasOwnProperty, you can use:

Object.hasOwn(child, "name"); // true

This is safer and recommended in modern JavaScript.

Key Idea

When you access a property:

  1. JavaScript looks at the object itself
  2. If not found, looks in the prototype
  3. Continues up the chain until null

This lookup mechanism is the foundation of inheritance, method sharing, and many “weird” JS behaviors.

JavaScript does not copy behavior between objects — it links them through the prototype chain.


The this Keyword

The value of this in JavaScript is determined by how a function is called, not where it is defined.
This is one of the most confusing parts of JavaScript, but the rule is simple:

this depends on the call site — how the function is invoked.

Global Context

In the global execution context:

console.log(this);
  • In browser → window
  • In Node.js → global (or {} in modules)

Function Call (Default Binding)

function show() {
  console.log(this);
}

show();
  • In non-strict mode → window
  • In strict mode → undefined

Method Call (Implicit Binding)

let obj = {
  name: "Aayush",
  greet() {
    console.log(this.name);
  }
};

obj.greet(); // "Aayush"

this refers to the object before the dot (obj).

Losing this

let obj = {
  name: "Aayush",
  greet() {
    console.log(this.name);
  }
};

let fn = obj.greet;
fn(); // undefined or error

The function is now called independently, so this falls back to default binding — losing the reference to obj.

Explicit Binding (call, apply, bind)

You can manually set what this refers to.

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

let obj = { name: "Aayush" };

greet.call(obj);   // "Aayush"
greet.apply(obj);  // "Aayush"
  • call → arguments passed individually
  • apply → arguments passed as an array

bind — Permanent Binding

let boundFn = greet.bind(obj);
boundFn(); // "Aayush"
  • Returns a new function
  • this is permanently bound and cannot be overridden

Constructor Function (new Binding)

function Person(name) {
  this.name = name;
}

let p1 = new Person("Aayush");
console.log(p1.name); // "Aayush"

When using new:

  1. A new empty object is created
  2. this points to that new object
  3. The object is returned

Arrow Functions (No Own this)

Arrow functions behave differently from regular functions.

let obj = {
  name: "Aayush",
  greet: () => {
    console.log(this.name);
  }
};

obj.greet(); // undefined

Arrow functions do not have their own this — they inherit this from their lexical (outer) scope. At the time greet is defined, the outer scope is global, so this is window, not obj.

Correct Use of Arrow Functions

let obj = {
  name: "Aayush",
  greet() {
    const inner = () => {
      console.log(this.name);
    };
    inner();
  }
};

obj.greet(); // "Aayush"

Here the arrow function is defined inside greet, so it inherits this from greet — which correctly points to obj.

Summary

How it’s calledthis refers to
Global scopewindow / global
Regular functionwindow (non-strict) / undefined (strict)
Method on objectThe object before the dot
call / apply / bindWhatever you pass in
newThe newly created object
Arrow functionthis from outer lexical scope

Arrow functions don’t bind this — they inherit it. Regular functions bind this based on how they’re called.


Conclusion

By now, you’ve seen how JavaScript actually works under the hood.
Instead of memorizing rules or gotchas, you now have a mental model:

  • How code executes
  • How scope and closures work
  • How async behavior works
  • How objects and this behave

Once you understand these, most “weird” JavaScript behavior starts making sense.
JavaScript is not something you memorize — it’s something you understand.


Resources I Recommend

If you want to go deeper, these are some of the best resources:


Edit page