JavaScript (ES2026) Module 4 – Functions, scope and closures
Scope, the scope chain and execution contexts
Trace how JavaScript resolves names through block, function, module and global scope, see execution contexts and the call stack, and avoid accidental globals.
What you will learn
- Trace how a name is resolved through block, function, module and global scope
- Explain execution contexts, the call stack and environment records at a high level
- Avoid accidental globals and shadowing bugs
- Predict which top-level declarations classic scripts share and modules keep to themselves
Before you start
What you’ll learn · Before you start
What you will learn
- Trace how a name is resolved through block, function, module and global scope
- Explain execution contexts, the call stack and environment records at a high level
- Avoid accidental globals and shadowing bugs
- Predict which top-level declarations classic scripts share and modules keep to themselves
Before you start
On this page
Scope is the part of a program in which a name can be used. JavaScript decides it lexically: from where the
code is written, not from where or how a function is called. Blocks, function bodies, modules and the global level
each hold their own names, and they nest like boxes inside boxes. To find a name, the engine looks in the innermost
scope first and then in each enclosing one, out to the global level; that path is the scope chain, and a name
found nowhere is a ReferenceError. While the program runs, the engine also keeps track of what is running with
execution contexts: each function call gets one, holding that call’s variables, and the contexts of the calls
that have not finished yet form the call stack. The two chains are easy to confuse. This lesson shows both in
programs that run, and the accidental globals and shadowing bugs they explain.
Four kinds of scope
- Block scope. A pair of braces,
{ … }, as in anif, a loop or a bare block, holds thelet,constandclassdeclarations written in it. In strict code, which includes every module, a function declared in a block belongs to that block too. - Function scope. Each call of a function has its parameters, its
vardeclarations and everything declared at the top of its body. - Module scope. The top level of an ES module holds its imports and top-level declarations. Other modules see only what it exports.
- Global scope. Outside every function and module sit the built-ins (
console,Math,globalThis) and, in classic scripts, the scripts’ top-level declarations.
var is the exception to block scope: it ignores blocks and belongs to the whole function, or to the global object at
the top of a classic script. Modern code uses let and const; the next lesson of this module looks at var and the
rules for using a name before its declaration.
The scope chain: looking a name up
Here one name, level, is declared four times at four levels. Each line prints the declaration that a use of the
name found:
// One name, four scopes. Each line says which declaration the name level resolved to.
const level = 'module';
function outer() {
const level = 'function outer';
if (level !== '') {
const level = 'block inside outer';
console.log(`in the block: ${level}`);
}
function inner() {
console.log(`in inner: ${level}`); // inner has no level of its own: the next scope out is outer's
}
inner();
console.log(`in outer: ${level}`);
}
outer();
console.log(`at the top of the module: ${level}`);
// A name that no scope declares is a ReferenceError, not undefined.
try {
// eslint-disable-next-line no-undef -- the misspelt name is the point
console.log(levl);
} catch (error) {
console.log(`${error.name}: ${error.message}`);
} Output
in the block: block inside outer in inner: function outer in outer: function outer at the top of the module: module ReferenceError: levl is not defined
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node scope-chain.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Version note
When you press Run, QuickJS quotes the missing name: ReferenceError: 'levl' is not defined.
In your browser, QuickJS (quickjs-emscripten 0.32.0) prints:
in the block: block inside outer in inner: function outer in outer: function outer at the top of the module: module ReferenceError: 'levl' is not defined
- Inside the
ifblock, the nearest declaration is the block’s own, so that one wins. The conditionlevel !== ''sits outside the braces, so it still readsouter’slevel. innerdeclares nolevel, so the lookup moves out one scope, toouter’s function scope, and stops at the first match. It never reaches the module’slevel.- After the block has ended, its
levelis gone, andoutersees its own again. - At the top of the module, only the module’s declaration is in reach. Inner scopes can see out; outer scopes cannot see in.
The last lines show what happens when the chain runs out: the misspelt levl is declared nowhere, and reading it
throws a ReferenceError. That is a feature. A typo that silently gave undefined would surface much later, far from
the line with the mistake.
Scope is lexical, not decided by the caller
Where a function is called makes no difference to the names it sees. What counts is where its code sits:
// Scope comes from where a function is written, not from where it is called.
const rate = 18;
function withTax(pricePaise) {
return Math.round(pricePaise * (1 + rate / 100)); // this rate is the module's, always
}
function quoteForExport(pricePaise) {
const rate = 0; // a different rate, local to this function
return `export quote: ${withTax(pricePaise)} (local rate ${rate})`;
}
console.log(withTax(10000));
console.log(quoteForExport(10000));
// A function created inside another sees that function's variables, even when it is called elsewhere.
function makeGreeter(greeting) {
return function greet(name) {
return `${greeting}, ${name}`;
};
}
const greetHindi = makeGreeter('Namaste');
const greeting = 'Hello'; // does not affect greetHindi
console.log(greetHindi('Asha'), '|', greeting); Output
11800 export quote: 11800 (local rate 0) Namaste, Asha | Hello
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node lexical-scope.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
withTax() is written at the top of the module, so rate inside it means the module’s rate, 18, even when
quoteForExport() calls it from inside a function that has a rate of its own. If JavaScript looked names up
through the callers instead (dynamic scope, which some other languages use), the export quote would have used 0.
The second half shows the same rule from the other side: greet() is written inside makeGreeter(), so it keeps
seeing that call’s greeting, 'Namaste', after makeGreeter() has returned and even though the module later
declares its own greeting. A function together with the names it can reach this way is a closure, the subject
of a later lesson of this module.
Execution contexts and the call stack
The specification describes running code with execution contexts. One is created for the module or script when
it starts and one for every function call; each tracks the code being run and the names it can use. The contexts sit
on the execution context stack: a call pushes a new context, which becomes the running execution context, and
a return pops it, so the caller’s context is running again. Debuggers and stack traces show the same stack, as
frames. This program prints the stack, as V8 reports it, at four moments:
// The call stack while nested calls run: each line lists the running function first, then its callers.
// The names come from V8's stack trace (error.stack is not standard, so its format differs between engines).
function stack() {
return new Error().stack
.split('\n')
.slice(2) // drop the "Error" line and the frame of stack() itself
.map((line) => line.trim().match(/^at (\S+) \(/)?.[1] ?? 'module code')
.filter((name, i, all) => all.indexOf('module code') === -1 || i <= all.indexOf('module code'))
.join(' <- ');
}
function applyCoupon(totalPaise) {
console.log(`3. ${stack()}`);
return totalPaise - 5000;
}
function priceCart(items) {
console.log(`2. ${stack()}`);
const total = items.reduce((sum, item) => sum + item, 0);
return applyCoupon(total);
}
function checkout(items) {
console.log(`1. ${stack()}`);
const due = priceCart(items);
console.log(`4. back in checkout: ${stack()}`);
return due;
}
console.log(`due: ${checkout([40000, 25000])}`); Output
1. checkout <- module code 2. priceCart <- checkout <- module code 3. applyCoupon <- priceCart <- checkout <- module code 4. back in checkout: checkout <- module code due: 60000
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node call-stack.mjs
At step 3 the stack holds four contexts: applyCoupon is running, priceCart and checkout are waiting for the
calls they made, and the module’s code is waiting at the bottom. By step 4, priceCart and applyCoupon have
returned and their contexts are gone, with their local variables. The reduce() callback ran in between and left no
trace either: a context exists only while its call runs. The example has no Run button because it reads V8’s
error.stack, which is not standard; QuickJS reports the same calls in its own format.
The call stack and the scope chain are different chains, and this is the point that makes scope click. At step 3,
the call stack runs through priceCart and checkout, but applyCoupon cannot see either function’s variables,
because it was not written inside them:
The call stack and the scope chain are different chains
Text description of the diagram
The diagram shows two chains at the same moment of `call-stack.mjs`, while `applyCoupon` runs.
- The call stack, on one side, records who called whom: `applyCoupon` is running, it was called by `priceCart`, which was called by `checkout`, which was called by the module code.
- The scope chain of `applyCoupon`, on the other side, records where names are looked up. It starts with the environment of the running call, which holds the parameter `totalPaise`. Its outer environment is the module's, which holds `checkout`, `priceCart`, `applyCoupon` and `stack`, because `applyCoupon` is written at the top of the module. The module's outer environment is the global one, with built-ins such as `console` and `Math`.
- `priceCart` and `checkout` are on the call stack but not on the scope chain: `applyCoupon` cannot see their local variables, however it was called.
Environment records, at a high level
In the specification, every scope is an environment record: a table of the names (the bindings) that one
block, function call, module or the global level declares. Each record has an outer reference to the record
around it, and those references are the scope chain. Looking a name up means checking the running context’s record,
then its outer record, and so on, until the global record; a miss there is the ReferenceError.
Two details explain the examples above:
- A function object remembers the environment record it was created in. Each call creates a new function environment record for its parameters and variables, and that record’s outer reference is the remembered one, not the caller’s. That is lexical scope, written as a rule.
- A new record is created each time its code runs: each call of a function, and each pass through a block, gets its own set of bindings. Two calls of the same function never share their local variables.
The specification notes that environment records are a way to describe the language and need not exist inside an engine as such; engines may store variables however they like, as long as programs behave as described.
Scripts and modules at the top level
A browser runs each <script> tag without type="module" as a classic script, and all classic scripts of a page
share one global scope. The global environment has two halves: the global object (window in a browser,
globalThis everywhere), which holds the built-ins and the top-level var and function declarations of scripts, and
a separate table for the scripts’ top-level let, const and class. Node.js’s vm module runs code as classic
scripts, which shows the rules without a browser:
// Classic scripts share one global scope. node:vm runs each string below as a classic script, the way a browser runs
// each <script> tag without type="module".
import { runInThisContext } from 'node:vm';
runInThisContext('var cart = 3; let user = "asha"; function total() { return cart * 2; }');
console.log(`globalThis has cart: ${'cart' in globalThis}, user: ${'user' in globalThis}, total: ${'total' in globalThis}`);
// A second script sees the first script's let, even though it is not a property of globalThis.
console.log(`second script reads: ${runInThisContext('`${user} ${total()}`')}`);
// A third script that declares the same name again fails before any of its code runs.
try {
runInThisContext('console.log("third script starts"); let user = "ravi";');
} catch (error) {
console.log(`${error.name}: ${error.message}`);
}
// The module's own top-level names are invisible to scripts.
const moduleOnly = 'module';
console.log(`the module sees ${moduleOnly}; a script sees moduleOnly as: ${runInThisContext('typeof moduleOnly')}`); Output
globalThis has cart: true, user: false, total: true second script reads: asha 6 SyntaxError: Identifier 'user' has already been declared the module sees module; a script sees moduleOnly as: undefined
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node two-scripts.mjs
- The first script’s
var cartandfunction totalbecame properties ofglobalThis; itslet userdid not. - A second script still sees
userand callstotal(): top-level declarations of classic scripts are shared by every script that runs later. - A third script that declares
useragain fails before any of its code runs, so its first line never prints: before running a script, the engine checks all of its top-level declarations against the names that already exist. Two third-party snippets on one page that both uselet configcollide this way. - The module’s own
moduleOnlyis invisible to scripts. Each module has its own module scope, so modules never collide like that, and at the top of a modulethisisundefined.
The example has no Run button because QuickJS has no node:vm.
Accidental globals
In sloppy mode, the old default of classic scripts, assigning to a name that no scope declares does not fail: it
creates a property on the global object. Strict mode turns that into a ReferenceError:
// Assigning to a name that no scope declares. Indirect eval runs its code the way a classic script runs: at the
// global level and, without "use strict", in sloppy mode.
// eslint-disable-next-line no-eval -- indirect eval runs each string as a classic script would run
const runAsScript = eval;
runAsScript('function addVisit() { visits = 1; } addVisit();');
console.log(`sloppy script: visits became a global property: ${'visits' in globalThis}, value ${globalThis.visits}`);
try {
runAsScript('"use strict"; function addSale() { sales = 1; } addSale();');
} catch (error) {
console.log(`strict script: ${error.name}: ${error.message}`);
}
// Module code is always strict, so the same mistake fails at once.
function addRefund() {
// eslint-disable-next-line no-undef -- the undeclared name is the mistake this line shows
refunds = 1;
}
try {
addRefund();
} catch (error) {
console.log(`module: ${error.name}: ${error.message}`);
} Output
sloppy script: visits became a global property: true, value 1 strict script: ReferenceError: sales is not defined module: ReferenceError: refunds is not defined
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node accidental-global.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Version note
The QuickJS engine behind the Run button prints both errors with the name in quotes, for example
ReferenceError: 'sales' is not defined.
In your browser, QuickJS (quickjs-emscripten 0.32.0) prints:
sloppy script: visits became a global property: true, value 1 strict script: ReferenceError: 'sales' is not defined module: ReferenceError: 'refunds' is not defined
Indirect eval, const runAsScript = eval; runAsScript(code), runs code at the global level and in sloppy mode unless
the code asks for strict mode, much like a classic script (only its let and const stay inside the eval). In the
first, sloppy, script, visits = 1 quietly created globalThis.visits: any other code could read or
overwrite it, and a typo in a variable name becomes a new global instead of an error. The second script starts with
"use strict", and the same assignment throws. Module code is always strict, so addRefund() fails at the first call.
Modules, classes and "use strict" all prevent accidental globals; ESLint’s no-undef rule, part of its recommended
set, reports them before the code runs.
Shadowing
A declaration in an inner scope with the same name as one outside shadows it: inside the inner scope, the name means the inner declaration. That is legal, and sometimes useful, but it hides bugs:
/* eslint-disable prefer-const, @typescript-eslint/no-unused-vars -- these rules report the shadowing bugs this file shows */
// Shadowing: an inner declaration with the same name hides the outer one for the rest of its scope.
function longestName(names) {
let longest = '';
for (const name of names) {
if (name.length > longest.length) {
let longest = name; // a new variable for this block only: the outer longest never changes
}
}
return longest;
}
function longestNameFixed(names) {
let longest = '';
for (const name of names) {
if (name.length > longest.length) {
longest = name; // no let: this updates the outer variable
}
}
return longest;
}
const names = ['Ravi', 'Meenakshi', 'Asha'];
console.log(JSON.stringify(longestName(names)), JSON.stringify(longestNameFixed(names)));
// An inner declaration shadows the outer name from the start of its block, even on lines above it.
const discountPercent = 10;
function finalPrice(pricePaise) {
try {
const half = discountPercent / 2; // reads the inner discountPercent, which is not initialised yet
const discountPercent = half;
return pricePaise - (pricePaise * discountPercent) / 100;
} catch (error) {
return error.name;
}
}
console.log(finalPrice(10000)); Output
"" "Meenakshi" ReferenceError
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node shadowing.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
longestName() returns "" for every list. The let longest = name; inside the if declares a new longest that
lives only in that block, so the outer longest is never updated. Dropping the let, as in longestNameFixed(),
assigns to the outer variable.
The second function reads discountPercent on a line above the inner declaration, and still gets the inner one:
a declaration shadows the outer name from the start of its block, not from its own line. Until its line runs, the
inner name is uninitialised, so the read throws a ReferenceError instead of falling back to the outer 10. Renaming
the inner variable, here to something like halfDiscount, fixes both kinds of shadowing bug. ESLint’s no-shadow
rule reports every shadowing declaration; it is not in the recommended set, so a project turns it on deliberately.
Even without it, no-unused-vars notices that the inner longest is never read and prefer-const that the outer one
never changes, which is why the example switches both rules off in its first line.
Interview questions
Fresher · What does lexical scope mean in JavaScript?
Lexical scope means that the names a piece of code can use are decided by where the code is written in the source,
not by where or how it is called. Blocks, functions, modules and the global level nest, and a name is looked up from
the innermost scope outwards along that nesting; this path is the scope chain, and a name declared nowhere throws a
ReferenceError. So a function written at the top of a module sees the module’s variables even when another function,
with variables of the same names, calls it. A function created inside another function keeps seeing that outer
call’s variables after it returns, which is what a closure is. The call stack is a separate chain: it records who
called whom, and it plays no part in finding names.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A page loads analytics.js and then app.js as classic scripts. After analytics.js is updated, app.js stops with "SyntaxError: Identifier 'config' has already been declared", and none of its lines run. Explain why, why var in both files would have hidden the problem, and how you fix it for good.
-
Mid to senior
In a classic browser script, var name = { first: 'Asha' }; console.log(name.first); prints undefined. The same two lines in a module, or in Node.js, print Asha. Explain the difference, and what it tells you about top-level declarations in classic scripts.
Key takeaways
- Scope is where a name can be used.
let,constandclassare block-scoped; parameters andvarbelong to the function; a module has its own top-level scope; the global scope holds the built-ins. - A name is looked up from the innermost scope outwards, and the first match wins. A name found nowhere is a
ReferenceError. - Scope is lexical: a function sees the names around the place where it was written, never its caller’s.
- Each call creates an execution context; the stack of unfinished contexts is the call stack. It records who called whom, which is a different chain from the scope chain.
- Classic scripts share one global scope:
varand functions becomeglobalThisproperties, and a second top-levelletof the same name is aSyntaxError. Modules keep their names to themselves. - Strict code, which includes every module, turns an assignment to an undeclared name into a
ReferenceError. Shadowed names hide updates and apply from the start of their block; rename them, and letno-undefandno-shadowfind the rest.
Exercise
Exercise · Easy · JavaScript
Fix an accidental global and a shadowed variable
tally.mjs came from an old classic script and has two scope bugs. Fix both without changing what the functions are meant to do.
countWords(text)returns an object that maps each word, in lower case, to the number of times it appears:countWords('The cat saw the hat')is{ the: 2, cat: 1, saw: 1, hat: 1 }. In the old script it also created a global variable by accident; in a module, which is strict, the same line throws aReferenceError. Nothing may be left onglobalThis.longestWord(text)returns the longest word of the text, the first one when several are equally long, and''for a text with no words. It returns''for every text, because an inner declaration hides the variable it should update.
Run the sample tests when you are done.
Starter code · tally.mjs
// Two helpers from an old classic script (the exercise of lesson javascript/scope-and-execution-contexts). Each one
// has a scope bug.
/* eslint-disable no-undef, prefer-const, @typescript-eslint/no-unused-vars -- the two bugs, which these rules report */
export function countWords(text) {
counts = {};
for (const word of text.toLowerCase().split(/\s+/).filter(Boolean)) {
counts[word] = (counts[word] ?? 0) + 1;
}
return counts;
}
export function longestWord(text) {
let longest = '';
for (const word of text.split(/\s+/)) {
if (word.length > longest.length) {
let longest = word;
}
}
return longest;
} The sample tests · tally.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { countWords, longestWord } from './tally.mjs';
test('countWords counts each word in lower case', () => {
assert.deepEqual(countWords('The cat saw the hat'), { the: 2, cat: 1, saw: 1, hat: 1 });
});
test('countWords leaves nothing on globalThis', () => {
countWords('one two one');
assert.equal('counts' in globalThis, false);
});
test('countWords of an empty text is an empty object', () => {
assert.deepEqual(countWords(' '), {});
});
test('longestWord finds the longest word', () => {
assert.equal(longestWord('a quick brown fox'), 'quick');
});
test('longestWord keeps the first of equally long words, and handles no words', () => {
assert.equal(longestWord('red tan sky'), 'red');
assert.equal(longestWord(''), '');
}); A hint
Look at the first line inside countWords(): the name it assigns is declared nowhere, so give it a const or let. In longestWord(), the let inside the if block creates a second longest that exists only in that block; drop the keyword so that the assignment updates the outer variable.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- ECMAScript 2026 Language Specification: environment records (Ecma International)
- ECMAScript 2026 Language Specification: execution contexts (Ecma International)
- ECMAScript 2026 Language Specification: global environment records (Ecma International)
- ECMAScript 2026 Language Specification: GlobalDeclarationInstantiation (Ecma International)
- ECMAScript 2026 Language Specification: the strict mode of ECMAScript (Ecma International)
- Scope (MDN glossary) (MDN Web Docs)
- Node.js v24 documentation: VM (executing JavaScript) (OpenJS Foundation (Node.js))
- ESLint rule: no-shadow (OpenJS Foundation (ESLint))
- ESLint rule: no-undef (OpenJS Foundation (ESLint))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress