Most people begin JavaScript by building things. You wire up a button, fetch some data, and watch the DOM change. Then the abstractions leak. Bugs surface that make no sense: variables exist before their declarations, functions remember variables they should not have access to, and the this keyword points at the window, a button, or nothing at all. That is usually the moment you realize you need to look under the hood and understand what the engine is actually doing.

Execution Context: The Two-Phase Setup

When the JavaScript engine in your browser or Node.js encounters a script, it does not simply read the file from top to bottom like a person scanning a page. Instead, it builds an execution context, a container that holds everything needed to run a particular chunk of code. Every execution context moves through two distinct phases.

Memory Creation Phase. During this initial pass, the engine scans the entire scope and allocates memory for every variable and function declaration it finds. If it sees a var, it reserves space and stores undefined as a placeholder. If it sees a function declaration, it stores the complete function body. This is why a function declared with the traditional function keyword can be called from lines that appear earlier in the same scope. The engine already knows about it before it starts executing.

Code Execution Phase. Now the engine runs your code line by line. Assignments happen here. Expressions are evaluated. Functions are invoked. If you wrote var name = "Alice";, the placeholder from phase one finally gets replaced with the string. Understanding this two-pass behavior clears up a surprising amount of confusion. The engine is not sloppy; it is following a strict setup routine.

Variables and the Temporal Dead Zone

Choosing between var, let, and const is not just a stylistic preference. var is function-scoped, which means it ignores braces entirely. Declare it inside an if block, and it leaks outward. That behavior might have made sense in early JavaScript, but in modern applications it causes real maintenance headaches. let and const are block-scoped. They respect curly braces and disappear when the block ends.

There is also a subtle but crucial difference in how hoisting treats them. var declarations are hoisted and immediately initialized with undefined. let and const are technically hoisted too; the engine knows they exist before it reaches the declaration line. But they are not initialized. They sit in a limbo called the temporal dead zone. If you try to read them before the declaration line executes, you get a hard ReferenceError rather than a sneaky undefined. That crash is actually helpful. It prevents logic from proceeding on top of uninitialized data.

Lexical Scope and Closures

Scope answers a simple question: where can I access this variable? JavaScript uses lexical scope, which means a function's access rights are decided by where it was physically written in the source code, not by where it ends up being called. If you define a function inside another function, the inner one can reach outward to read variables from its parent. The outer function cannot reach inward. This relationship is static. You could pass that inner function across modules, store it in a global variable, and call it from an entirely different file. It still remembers the variables from the scope where it was born.

That behavior naturally produces closures. A closure forms when an inner function keeps a reference to a variable from its outer scope. Even after the outer function finishes executing and its local variables should have been garbage collected, JavaScript preserves them in memory because the inner function still needs them. The inner function carries its surrounding environment with it.

This is not merely an academic detail. Closures give you a practical way to create private state in a language that lacks explicit access modifiers.

function makeCounter() {
  let count = 0;
  return function() {
    count = count + 1;
    return count;
  };
}

const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2

Here, count is hidden. Nothing outside the returned function can reset it or read it directly. That is a private variable built from scope mechanics alone.

Hoisting in Practice

It is common to hear that JavaScript "moves declarations to the top." That is a useful mental model, but the code does not actually get rewritten. During the memory creation phase, the engine simply registers declarations before execution begins. A statement like var x = 5; behaves as if the declaration and initialization are decoupled. The declaration var x; is processed early and initialized to undefined. The assignment x = 5; stays exactly where you wrote it and runs during the execution phase.

Because of this, var can lead to surprising results. A variable used near the top of a function might hold undefined even though a giant assignment sits at the bottom. Using let and const eliminates that particular foot-gun because the temporal dead zone forces you to keep your declarations above your usage.

How this Gets Its Value

If execution context and scope determine where variables live, this determines which object is currently in charge. Unlike lexical variables, this is not decided by where a function is written. It is decided entirely by how the function is called.

Default binding occurs when you invoke a plain, standalone function. In non-strict mode, this falls back to the global object. In a browser, that is window. Call a function without any context, and you might accidentally be touching global state without realizing it.

Implicit binding happens when you call a function as a method on an object. If you write user.sayName(), the dot quietly tells the engine to set this to user for the duration of that call. The site of the call matters more than the site of the definition.

Explicit binding lets you override everything manually. call() and apply() invoke a function immediately while forcing this to be a specific object you provide. The only difference between them is how arguments are passed: call takes a comma-separated list, while apply takes an array. bind() works differently. It does not invoke the function right away. Instead, it returns a new function with this permanently locked to the value you supplied. This is invaluable for callbacks that might otherwise lose their context when passed around.

New binding comes into play when you use the new keyword in front of a function call. The engine constructs a brand-new empty object, sets its prototype linkage, and points this inside the constructor to that fresh instance.

Writing Code That Lasts

Understanding mechanics is only half the craft. The other half is writing code that humans can read six months from now.

DRY (Don't Repeat Yourself) sounds obvious, but it is violated constantly. If you find yourself writing the same validation logic or API call pattern in three different files, extract it. Write one function. One source of truth means one place to update when requirements change, and that saves time in ways that are difficult to overstate.

KISS (Keep It Simple, Stupid) is a defense against ego. Nested ternaries and one-liner closures feel clever, but they cost hours during debugging. The closure pattern I showed earlier is powerful, yet nesting five levels deep just because you can is a mistake. Simple code survives team turnover, production incidents, and those middle-of-the-night alerts when something breaks and nobody remembers why.

The Real Payoff

Studying execution