JavaScript Scope and Closures Get Certified
Scope determines where variables and functions are accessible in your code. Closures are a powerful consequence of how scope works in JavaScript — they let functions “remember” the environment in which they were created.
In this lesson, you will learn about global, function, and block scope, the scope chain, variable shadowing, how closures work, and common patterns and pitfalls.
Global Scope
Variables declared outside any function or block belong to the global scope and can be accessed from almost anywhere in the program.
What this code does: A variable declared at the top level is global.
Both the function and the code outside it can read and use appName.
const appName = "Task Manager";
function showAppName() {
console.log("App:", appName);
}
showAppName();
console.log("Still accessible:", appName);
const appName = "Task Manager"; is declared at the top level, outside any function or block, so it is in the global scope. It can be read from almost anywhere in the program. The blank line on line 2 only separates the sections visually.function showAppName() { declares a function with no parameters. Nothing runs yet; the body is only stored.console.log("App:", appName); uses appName, which is not declared inside the function. JavaScript does not find it in the function’s own scope, so it looks in the surrounding (global) scope, where it exists. This works because inner scopes can see outer scopes.} ends the function body. Line 6 is a blank separator line.showAppName(); runs the function, which prints App: Task Manager.console.log("Still accessible:", appName); uses the same global variable from the top-level code. The output is Still accessible: Task Manager.Function Scope
Variables declared inside a function (with var,
let, or const) are only accessible
within that function.
What this code does: Variables declared inside a function are local to that function.
Trying to access secret outside the function causes a ReferenceError.
function hideSecret() {
const secret = "only visible inside";
console.log("Inside:", secret);
}
hideSecret();
try {
console.log(secret);
} catch (err) {
console.log("Error:", err.message);
}
function hideSecret() { declares a function. Everything declared inside its braces belongs to the function’s own function scope.const secret = "only visible inside"; creates a variable inside the function. It is local: it exists only while the function runs, and only code inside the function can see it.console.log("Inside:", secret); reads the variable from inside the function, where it is visible. The output is Inside: only visible inside.} ends the function body. Line 5 is a blank separator line.hideSecret(); runs the function and prints the line above. When the function finishes, the local variable secret is gone. Line 7 is blank.try { starts a try block so that the error we expect can be caught and displayed instead of stopping the program.console.log(secret); tries to read secret from the outer code, where it does not exist. This throws a ReferenceError.} catch (err) { runs because an error was thrown. The error object is stored in err.console.log("Error:", err.message); prints the message: Error: secret is not defined.} closes the catch block.Block Scope
let and const are block-scoped. A block
is any code wrapped in curly braces { } (if
statements, loops, etc.).
What this code does: let and const respect block scope.
The variable declared inside the if block is not accessible outside it.
if (true) {
let message = "I only exist inside this block";
console.log(message);
}
try {
console.log(message);
} catch (err) {
console.log("Error:", err.message);
}
if (true) { starts an if block. The condition is always true, so the block always runs. The curly braces create a block scope, which is the area that let and const respect.let message = "I only exist inside this block"; creates a variable that belongs to this block only. It cannot be seen outside the braces.console.log(message); reads the variable inside the block, where it is visible. The output is I only exist inside this block.} ends the block, and message stops existing. Line 5 is a blank separator line.try { starts a try block to catch the error that is about to happen.console.log(message); tries to read message outside the block. It is out of scope, so a ReferenceError is thrown.} catch (err) { runs because of the error.console.log("Error:", err.message); prints Error: message is not defined. Had var been used instead of let, the variable would have been visible here, because var ignores block scope.} closes the catch block.
var is function-scoped (not block-scoped), which is
why let and const are generally preferred.
The Scope Chain
When JavaScript looks up a variable, it checks the current scope first, then works outward through each enclosing scope until it finds the variable (or runs out of scopes and throws an error). This is called the scope chain.
What this code does: Inner functions can access variables from outer scopes (the scope chain).
printReceipt reads both the global storeName and the function-local orderId.
const storeName = "Downtown Books";
function processOrder() {
const orderId = "A123";
function printReceipt() {
console.log("Store:", storeName);
console.log("Order:", orderId);
}
printReceipt();
}
processOrder();
const storeName = "Downtown Books"; is declared at the top level, so it is in the global scope. Line 2 is a blank separator line.function processOrder() { declares a function. It creates its own function scope, which sits inside the global scope.const orderId = "A123"; is local to processOrder. It is visible inside this function and inside any function nested in it. Line 5 is blank.function printReceipt() { declares a function inside processOrder. It creates a third scope, nested inside the other two.console.log("Store:", storeName); needs storeName. JavaScript checks printReceipt‘s scope (not found), then processOrder‘s scope (not found), then the global scope (found). This outward search is the scope chain. The output is Store: Downtown Books.console.log("Order:", orderId); needs orderId, which is found one level up, in processOrder. The output is Order: A123.} closes printReceipt. Line 10 is a blank separator line.printReceipt(); runs the nested function from inside processOrder, where it can be seen.} closes processOrder. Line 13 is blank.processOrder(); starts everything. The two lines of output are Store: Downtown Books and Order: A123.
printReceipt can access orderId from its
parent function and storeName from the global scope,
because each inner scope has access to variables in the scopes
surrounding it.
Variable Shadowing
If a variable in an inner scope has the same name as one in an outer scope, the inner variable “shadows” the outer one within that scope.
What this code does: An inner variable can shadow an outer one with the same name.
Inside the function, mode is "dark"; outside, the original "light" is unchanged.
const mode = "light";
function toggleTheme() {
const mode = "dark";
console.log("Inside function:", mode);
}
toggleTheme();
console.log("Outside function:", mode);
const mode = "light"; is a global variable. Line 2 is a blank separator line.function toggleTheme() { declares a function that has its own scope.const mode = "dark"; declares a new variable, also called mode, inside the function. It does not change the global one. Inside the function, this inner mode shadows (hides) the outer one.console.log("Inside function:", mode); finds mode in the function’s own scope first, so it prints the inner value: Inside function: dark.} ends the function body. Line 7 is a blank separator line.toggleTheme(); runs the function and prints the line above.console.log("Outside function:", mode); is outside the function, so it sees only the global mode, which was never changed. The output is Outside function: light.What Is a Closure?
A closure is formed when a function “remembers” the variables from the scope it was created in, even after that outer scope has finished executing.
What this code does: A closure is a function that remembers variables from the scope where it was created.
Each greeter keeps its own greeting even after makeGreeter has finished running.
function makeGreeter(greeting) {
return function (name) {
console.log(greeting + ", " + name + "!");
};
}
const greetFormally = makeGreeter("Good day");
const greetCasually = makeGreeter("Hey");
greetFormally("Michael");
greetCasually("Michael");
function makeGreeter(greeting) { declares a function with a parameter greeting. Its job is to create and return another function.return function (name) { returns an anonymous function that takes a name. This inner function is created each time makeGreeter is called, and it can see greeting from the outer function.console.log(greeting + ", " + name + "!"); builds the greeting from greeting (which belongs to the outer function) and name (the inner function’s own parameter). Using the outer variable from inside is what makes this a closure.}; closes the inner function and ends the return statement.} ends makeGreeter. When it finishes, its variable greeting would normally be gone, but the returned function keeps it alive. Line 6 is a blank separator line.const greetFormally = makeGreeter("Good day"); calls makeGreeter with "Good day". It returns a new function, which remembers greeting = "Good day". That function is stored in greetFormally.const greetCasually = makeGreeter("Hey"); calls it again with "Hey". This creates a second function with its own separate remembered greeting. Line 9 is blank.greetFormally("Michael"); runs the first function, which still remembers "Good day", even though makeGreeter finished long ago. The output is Good day, Michael!.greetCasually("Michael"); runs the second function, which remembers "Hey". The output is Hey, Michael!.
Even though makeGreeter has already finished running
by the time greetFormally is called, the inner
function still remembers the value of greeting from
when it was created.
Using Closures to Create Private State
Closures are commonly used to keep a variable private, only accessible through functions that were created alongside it.
What this code does: Closures can create private state. count is hidden inside
createCounter; only the returned methods can change or read it. Each counter is independent.
function createCounter() {
let count = 0;
return {
increment: function () {
count++;
return count;
},
reset: function () {
count = 0;
}
};
}
const counter = createCounter();
console.log(counter.increment());
console.log(counter.increment());
console.log(counter.increment());
counter.reset();
console.log(counter.increment());
function createCounter() { declares a function that creates a counter each time it is called.let count = 0; creates a variable inside the function. Nothing outside can read or change it directly, so it is private. Line 3 is a blank separator line.return { starts returning an object whose properties are functions. These functions are the only way to reach count from the outside.increment: function () { defines a method that will increase the count.count++; adds 1 to count. The method can do this because it was created in the same scope as count: it is a closure that remembers the variable.return count; gives the updated value back to the caller.}, closes the method. The comma separates it from the next property.reset: function () { defines the second method.count = 0; sets the private count back to 0.} closes the second method. There is no comma because it is the last property.}; closes the object and ends the return statement.} ends createCounter. Normally count would vanish now, but the two returned functions still use it, so it stays alive. Line 14 is a blank separator line.const counter = createCounter(); calls the factory. The returned object, with its two methods, is stored in counter. Its private count starts at 0.console.log(counter.increment()); increases the count to 1 and prints 1.2. The variable remembers its value between calls.3.counter.reset(); sets the private count back to 0. It prints nothing.console.log(counter.increment()); starts again from 0, so it prints 1. The full output is 1, 2, 3 and 1. Note that counter.count is not accessible from outside at all.
The variable count cannot be accessed or modified
directly from outside createCounter — the only way
to interact with it is through the returned
increment and reset functions.
A Classic Pitfall: Closures Inside Loops
A well-known bug happens when creating closures inside a loop
using var, since var is function-scoped
rather than block-scoped.
What this code does: Classic loop-closure bug with var: all callbacks share the same
i, which ends up as 4. Every callback prints 4.
const callbacks = [];
for (var i = 1; i <= 3; i++) {
callbacks.push(function () {
console.log("var version:", i);
});
}
callbacks[0]();
callbacks[1]();
callbacks[2]();
const callbacks = []; creates an empty array that will hold three functions. Line 2 is a blank separator line.for (var i = 1; i <= 3; i++) { counts from 1 to 3. The key detail is var: a var variable is not block-scoped, so there is only one i shared by the whole loop and everything created in it.callbacks.push(function () { creates a new function on each pass and adds it to the array with push(). Nothing inside the function runs yet.console.log("var version:", i); is the body of each stored function. It reads i only when it is called, not when it was created. Each function remembers the variable i itself, not the value that i had at the time.}); closes the function, the call to push() and the statement.} ends the loop. When the loop finishes, i has become 4 (the value that made i <= 3 false). Line 8 is a blank separator line.callbacks[0](); runs the first stored function. It reads the shared i, which is now 4, and prints var version: 4.callbacks[1](); also reads the same i and prints var version: 4.callbacks[2](); prints var version: 4 as well. All three print 4 instead of 1, 2 and 3. This is the classic closure bug.
Because there is only one shared i, every callback
ends up referencing its final value after the loop finishes,
printing 4 three times.
What this code does: Using let in the loop creates a new binding per iteration.
Each callback captures its own i and prints 1, 2, and 3 correctly.
const callbacks = [];
for (let i = 1; i <= 3; i++) {
callbacks.push(function () {
console.log("let version:", i);
});
}
callbacks[0]();
callbacks[1]();
callbacks[2]();
const callbacks = []; creates an empty array for three functions. Line 2 is a blank separator line.for (let i = 1; i <= 3; i++) { is the same loop, but with let. A let variable in a for loop is block-scoped, and JavaScript creates a new i for each pass of the loop. That is the only change from the previous example.callbacks.push(function () { creates a function on each pass and adds it to the array. Each function is created in a different pass, so it is attached to a different i.console.log("let version:", i); reads i when the function is called. The first function remembers the i of pass 1 (value 1), the second one the i of pass 2 (value 2), and the third one the i of pass 3 (value 3).}); closes the function, the call to push() and the statement.} ends the loop. Line 8 is a blank separator line.callbacks[0](); prints let version: 1.callbacks[1](); prints let version: 2.callbacks[2](); prints let version: 3. Each closure captured its own separate value, which is what we wanted.
Using let instead creates a new block-scoped
i for each iteration, so each closure correctly
captures its own separate value.
Practical Example
Let’s use a closure to build a simple rate limiter that only allows an action a limited number of times.
What this code does: A practical closure: the returned function remembers attemptsUsed
and maxAttempts. Each call updates private state until the limit is reached.
function createLimiter(maxAttempts) {
let attemptsUsed = 0;
return function () {
if (attemptsUsed >= maxAttempts) {
return "Limit reached. Try again later.";
}
attemptsUsed++;
return "Attempt " + attemptsUsed + " of " + maxAttempts + " allowed.";
};
}
const loginAttempt = createLimiter(3);
console.log(loginAttempt());
console.log(loginAttempt());
console.log(loginAttempt());
console.log(loginAttempt());
function createLimiter(maxAttempts) { declares a function with a parameter for the maximum number of allowed attempts. Like greeting in an earlier example, maxAttempts will be remembered by the returned function.let attemptsUsed = 0; creates a private variable that counts the attempts made so far. It starts at 0. Line 3 is a blank separator line.return function () { returns an anonymous function. This is the closure that will keep attemptsUsed and maxAttempts alive between calls.if (attemptsUsed >= maxAttempts) { checks whether all the allowed attempts have been used.return "Limit reached. Try again later."; returns a refusal message and exits, without counting any further.} closes the if block.attemptsUsed++; adds 1 to the private counter. Because it is a closure, the change is remembered for the next call.return "Attempt " + attemptsUsed + " of " + maxAttempts + " allowed."; builds a status message from the two remembered variables and returns it.}; closes the returned function and ends the return statement.} ends createLimiter. Line 12 is a blank separator line.const loginAttempt = createLimiter(3); creates a limiter that allows 3 attempts. The returned function is stored in loginAttempt, with its own private attemptsUsed set to 0.console.log(loginAttempt()); is allowed: the counter becomes 1, and the output is Attempt 1 of 3 allowed.Attempt 2 of 3 allowed.Attempt 3 of 3 allowed.attemptsUsed (3) is not less than maxAttempts (3), so the guard returns Limit reached. Try again later. Notice that nothing outside the function can reset the counter.Scope Types Overview
| Scope Type | Created By | Accessible From |
|---|---|---|
| Global scope | Code outside any function or block | Anywhere in the program |
| Function scope | var, let, const inside a function | That function and any nested functions |
| Block scope | let, const inside { } | That block and any nested blocks |
Common Mistakes With Scope and Closures
- Using
varinside loops that create closures, leading to every closure sharing the same final value. - Assuming a variable declared inside a function or block is accessible outside of it.
- Accidentally shadowing an outer variable with an inner one of the same name, causing confusing bugs.
- Overusing global variables, which can be modified from anywhere and are harder to track down when something goes wrong.
- Assuming multiple calls to the same closure-creating function share the same private variables, when each call actually creates independent state.
Best Practices for Scope and Closures
- Declare variables in the smallest scope where they are actually needed.
- Prefer
letandconstovervarto take advantage of block scope. - Use closures intentionally to create private state instead of exposing internal variables globally.
- Avoid unnecessary global variables, which can be accidentally overwritten by unrelated code.
- Give shadowed variables different names when possible, to keep code easier to follow.
Quick Summary
- Global scope is accessible everywhere; function scope is limited to a single function; block scope is limited to a single block.
- The scope chain lets inner scopes access variables from surrounding outer scopes.
- A closure is a function that remembers variables from the scope where it was created, even after that scope has finished running.
- Closures are commonly used to create private, protected state.
- Each call to a function that creates a closure produces its own independent set of variables.
- Using
letinstead ofvarin loops avoids a classic closure bug.
Try It Yourself
Modify the code below. Try creating a second, independent bank account, and see how each one keeps track of its own balance separately.
What this code does: Practice example: a bank account with private balance via closure. Try creating a second account and see that each keeps its own independent balance. Click Test My Code to experiment.
function createAccount(startingBalance) {
let balance = startingBalance;
return {
deposit: function (amount) {
balance += amount;
return balance;
},
getBalance: function () {
return balance;
}
};
}
const account = createAccount(100);
console.log(account.deposit(50));
console.log(account.getBalance());
function createAccount(startingBalance) { declares a function that creates a bank account object with a starting balance. Each call will create a separate account.let balance = startingBalance; stores the balance in a variable that is private to this function call. It can only be reached through the methods returned below. Line 3 is a blank separator line.return { starts the returned object, which exposes two functions.deposit: function (amount) { defines a method that takes the amount to add.balance += amount; adds the amount to the private balance. This is shorthand for balance = balance + amount;.return balance; gives back the updated balance.}, closes the method; the comma separates the properties.getBalance: function () { defines a method that lets the outside world read the balance.return balance; returns the current private balance.} closes the second method.}; closes the returned object and ends the return statement.} ends createAccount. The methods keep balance alive. Line 14 is a blank separator line.const account = createAccount(100); creates an account whose private balance starts at 100.account.deposit(50) adds 50 to the balance (100 + 50) and returns the new balance. The output is 150.account.getBalance() returns the current balance, which was remembered by the closure. The output is 150. Try creating a second account with createAccount(500): each account keeps its own independent balance.Tip: A helpful way to think about closures: a function doesn’t just remember its own code — it remembers the entire neighborhood of variables it was born into, and it keeps that neighborhood alive for as long as the function itself exists.
Understanding scope and closures is essential before moving on to
the this keyword, higher-order functions, and more
advanced JavaScript concepts.
🧪 Test Your JavaScript Code
Edit the code on the left and click “Run Code” to see the console output on the right.
Click “Run Code” to see the output here.
Code Explanation
The complete example stays visible. Use Previous and Next to move through its logical blocks.
Select Next to begin exploring the code.