← Back to list

Scope in JavaScript: Where Variables Live and How the Engine Finds Them

Every variable you write lives somewhere, and that “somewhere” determines who can see it, who can change it, and when it gets cleaned up

Hunter Dev · 2026-01-30 14:39 · 0 claps · 7.7 min read
#javascript #javascript-scope
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Scope in JavaScript: Where Variables Live and How the Engine Finds Them

Every variable you write lives somewhere, and that “somewhere” determines who can see it, who can change it, and when it gets cleaned up. That’s scope, and understanding it changes how you write code.

In the previous part on Hoisting, we saw that the JavaScript engine registers declarations before running your code. But where those declarations are registered, and who gets access to them, is determined by scope.

Scope is a general concept in computer science: it refers to the parts of a program where a particular variable, function, or identifier can be accessed. Modern JavaScript has four main types:

  • Global scope
  • Function scope
  • Block scope
  • Module scope

Before we walk through each one, we need to understand the foundation they all share: lexical scope.

Lexical Scope: Scope by Structure

In JavaScript, the scope of every identifier is determined at compile time, before a single line executes. The engine figures out who owns what by analyzing the structure of your code, not by how it runs. This is called lexical scope (also known as static scope).

const myName = "John Doe";

function hello() {
  const greeting = "hello " + myName;

  console.log(greeting);
}

hello(); // "hello John Doe"

Here, myName and hello live in the global scope. The greeting variable lives inside the hello function's scope, it's local to that function. The engine knows this before any code runs, simply by reading where each declaration sits in the source.

Scopes can be nested inside each other. An inner scope has access to everything in its outer (parent) scope, but the outer scope can’t reach into the inner one.

Lexical vs. Dynamic scope: Some languages (like older Bash or Perl) use dynamic scope, where scope is determined at runtime based on the call stack. JavaScript uses lexical scope, what matters is where code is written, not from where it is called. This distinction becomes crucial when we get to Closures.

Global Scope

The global scope is the outermost scope, everything lives inside it. In a browser, the global scope is the window object.

Here’s a nuance worth knowing: variables declared with var and function declarations at the global level get added as properties on window. Variables declared with let or const at the global level are still global, but they don't become window properties.

var todoList = ["grocery", "exercise"];

function emptyTodoList() {
  todoList = [];
}

console.log(window.hasOwnProperty("todoList"));      // true
console.log(window.hasOwnProperty("emptyTodoList"));  // true
const todoList = ["grocery", "exercise"];

let emptyTodoList = function() {
  todoList = [];
};

console.log(window.hasOwnProperty("todoList"));      // false
console.log(window.hasOwnProperty("emptyTodoList"));  // false

Don’t pollute the global scope

You’ve probably heard “avoid polluting the global scope.” Here’s what that actually means and why it matters.

The global scope is the parent of all other scopes, which means everything declared there is visible everywhere. This opens the door to:

  • Name clashes — two different parts of your code accidentally using the same variable name
  • Shadowing bugs — local declarations hiding global ones in unexpected ways
  • Memory that never clears — the global scope exists for the entire lifetime of the page, so anything you declare there stays in memory until the tab is closed

The fix is simple: if a variable is only needed inside a function, declare it there. Keep global declarations to the bare minimum.

Implicit globals, a silent trap

JavaScript has a particularly sneaky quirk: if you assign a value to a variable that was never declared, JavaScript will silently create a global variable for you, in non-strict mode, anyway.

function printSquare(num) {
  result = num * num;   // result was never declared!

  console.log(result);  // 64
}

printSquare(8);

console.log("implicit global: " + result); // WHAT??!! — 64

Instead of throwing an error about an undeclared variable, JavaScript quietly creates result as a global. This is almost certainly a bug, not a feature.

⚠️ This only happens in non-strict mode. In strict mode ("use strict"), assigning to an undeclared variable throws a ReferenceError immediately. This is one of the strongest reasons to always write JavaScript in strict mode.

There’s also a second, lesser-known source of implicit globals: HTML element IDs. If an element has id="mainHeading", the browser adds mainHeading as a variable in your JavaScript's global scope automatically. This is called Named access on the Window object. Avoid relying on it, use getElementById or querySelector instead.

Function Scope

The function scope is the region of code within a function’s body, between its opening and closing curly braces. Variables declared inside a function are private to that function and can’t be accessed from outside.

function greetUser() {
  const message = "Hello!"; // only visible inside this function

  console.log(message);
}

greetUser();

console.log(message); // ReferenceError: message is not defined

Shadowing declarations

When a variable in an inner scope has the same name as one in an outer scope, the inner one shadows the outer one. Inside that inner scope, the outer variable becomes completely invisible.

let hobby = "reading";

function printHobbies() {
  const hobby = "traveling"; // shadows the outer hobby

  console.log(hobby);        // "traveling"
}

printHobbies();

console.log(hobby); // "reading" — outer is untouched

Shadowing isn’t always wrong, but it can reduce readability. Once you’ve shadowed a variable, code inside that inner scope has no way to reach the outer one, unless the outer variable was declared with var globally, in which case window.variableName can still reach it.

Function Parameter Scope

Here’s one most developers don’t know about: function parameters don’t always share the same scope as the function body. It depends on whether the parameter list is simple or non-simple.

  • Simple parameters — plain names, no defaults, no destructuring, no rest, behave as if they’re in the function’s local scope
  • Non-simple parameters — anything using default values, destructuring, or rest parameters, live in their own separate scope that sits between the outer scope and the function body

Here’s a code example that proves these are genuinely two different scopes:

function paramScope(arr = ["initial array"], buff = () => arr) {
  var arr = [1, 2, 3];       // this arr is in the function body scope

  console.log(arr);          // [1, 2, 3]
  console.log(buff());       // ["initial array"] — still the parameter arr!
}

paramScope();

The arr inside the function body shadows the arr parameter. So when buff runs, it was defined in the parameter scope, not the function body it sees the original parameter value, not the one assigned inside the function. Two variables, same name, different scopes.

Remove the var keyword and the two are now the same variable, giving a different result:

function paramScope(arr = ["initial array"], buff = () => arr) {
  arr = [1, 2, 3];            // now reassigning the parameter itself

  console.log(arr);          // [1, 2, 3]
  console.log(buff());       // [1, 2, 3] — same arr now
}

paramScope();

Named Function Expression (NFE) Scope

When you write a named function expression, that name is only visible inside the function body, but it’s not actually declared in the function body. It lives in its own tiny scope between the outer scope and the function body.

let fn = function namedFn() {
  let namedFn = 123; // this works — no re-declaration error

  console.log(namedFn); // 123
};

If namedFn were declared inside the function scope, the let namedFn = 123 line would throw a SyntaxError because let doesn't allow re-declaration. The fact that it doesn't error out proves the name lives in a separate, intermediate scope. The namedFn = 123 inside the body is simply shadowing it.

Block Scope

Before ES2015, JavaScript had no block scope. Variables declared with var inside an if block or a for loop would happily leak out, because var has function scope, not block scope.

if (true) {
  var leaked = "I escaped the block!";
}

console.log(leaked); // "I escaped the block!" — oops

ES2015 fixed this with let and const, which are properly block-scoped:

if (true) {
  let contained = "I stay inside";

  const alsoContained = "me too";
}

console.log(contained);     // ReferenceError ✓
console.log(alsoContained); // ReferenceError ✓

Block scope also solves the classic closure-in-loops problem, something we’ll look at closely in the Closures article.

Module Scope

ES modules (introduced in ES2015) brought a new type of scope: the module scope. Every file that runs as a module has its own scope. Declarations at the top level of a module are not added to the global scope, they’re private to the module by default.

// math.js — a module
const PI = 3.14159; // private to this module

export function circleArea(r) {
  return PI * r * r; // PI is accessible here
}
// app.js — importing the module
import { circleArea } from './math.js';

console.log(circleArea(5)); // 78.53..., works fine
console.log(PI);            // ReferenceError — not exported

Only what you explicitly export is visible to other files. Everything else stays inside the module. This is the cleanest way to avoid polluting the global scope in modern JavaScript.

The Scope Chain

When scopes are nested inside each other, they form a scope chain. Each scope is linked to its parent scope, and that linkage is what allows inner code to access outer variables.

When the engine encounters a variable it can’t find in the current scope, it walks up the chain, checking the parent scope, then the parent’s parent, all the way to the global scope. If it still can’t find the variable, a ReferenceError is thrown.

const myName = "John Doe";

function hello() {
  const greeting = "hello " + myName; // looks up scope chain for myName

  function greet() {
    console.log(greeting); // looks up scope chain for greeting
  }

  greet();
}

hello(); // "hello John Doe"

The greet function doesn't have greeting in its own scope, so it checks the parent, hello's scope, and finds it there. The hello function doesn't have myName, so it checks the global scope and finds it there. That's the scope chain working as intended.

A note on performance: the engine is smarter than you think

You might worry that traversing the scope chain on every variable lookup would be slow. It usually isn’t, because JavaScript’s scope is lexical (determined at compile time), the engine almost always knows exactly which scope a variable lives in before execution begins. It doesn’t need to walk the chain at runtime in most cases.

The exception is code where scope can’t be determined statically, like with eval() or with. Both force the engine to defer lookups to runtime, which is part of why both are considered bad practice.

All Six Scope Types at a Glance

“Scope isn’t about restricting access, it’s about giving variables the right amount of visibility. No more, no less.”

Advanced JavaScript Series

Thanks for reading! If scope clicked for you in a new way today, a few claps 👏 help this series reach more developers.

Next up: Coercion, why "50" - 20 equals 30, why "50" + 20 equals "5020", and how JavaScript's type conversion engine actually works under the hood. It's one of the most misunderstood topics in the language, and we'll demystify it completely.


메타데이터
post_id
6e0119ffbd3f
slug
scope-6e0119ffbd3f
url
https://medium.com/@xayrullohabduvohidov713/scope-6e0119ffbd3f
canonical_url
https://medium.com/@xayrullohabduvohidov713/scope-6e0119ffbd3f
author_url
https://medium.com/@xayrullohabduvohidov713
status
ok
fetched_at
2026-08-18 22:53:19