JavaScript For Loop
A for loop repeats a block of code a specific number of times, controlled by a counter you define. It is the workhorse loop of JavaScript: you tell it how to start, how long to keep going, and how to move forward each time, and it handles the repetition for you. Understanding it well means understanding how nearly every other loop in the language (and in most C-family languages) works under the hood.
Overview: How the For Loop Works
A for loop bundles three pieces of loop management into one line: an initializer that runs once before anything else, a condition that is checked before every iteration, and an update expression that runs after every iteration. As long as the condition evaluates to a truthy value, the loop body keeps executing.
Internally, the JavaScript engine treats a for loop as syntactic sugar over a sequence of steps it repeats: evaluate the condition, and if it is truthy, run the body, then run the update, then go back and check the condition again. This is exactly why a for loop can express “do this 10 times” or “walk through every item in this array” so naturally — the counter variable is just a piece of state the engine keeps alive across iterations.
When you declare the counter with let (the modern default), the engine actually creates a new binding of that variable for every iteration, copying forward the value from the previous iteration. This is subtle but important: it is why closures created inside a let-based loop each capture their own snapshot of the counter, while a var-based loop shares a single binding across all iterations. We’ll see this exact difference cause a real bug later in this lesson.
Syntax
The general form of a for loop is:
for (initialization; condition; update) {
// loop body
}
| Part | Runs when | Typical use |
|---|---|---|
| initialization | Once, before the loop starts | let i = 0 — declare and set the counter |
| condition | Before every iteration, including the first | i < array.length — if false, the loop stops immediately |
| update | After every iteration, before the condition is checked again | i++ — move the counter forward |
| body | Once per iteration, if the condition was truthy | The statements between { and } |
All three header parts are optional. for (;;) { ... } is a valid (infinite, unless you break) loop. You can also declare multiple counters using a comma, such as for (let i = 0, j = 10; i < j; i++, j--), which is handy when two values need to move in sync.
Examples
Example 1: Counting with a basic for loop
for (let i = 1; i <= 5; i++) {
console.log(`Count: ${i}`);
}
Output:
Count: 1
Count: 2
Count: 3
Count: 4
Count: 5
The counter i starts at 1. Before each iteration the engine checks i <= 5; since it’s true, the body runs and logs the current value, then i++ increments it. Once i becomes 6, the condition is false and the loop ends — the body never runs for i = 6.
Example 2: Iterating an array by index
const scores = [72, 88, 95, 60, 100];
let total = 0;
for (let i = 0; i < scores.length; i++) {
total += scores[i];
console.log(`Score ${i + 1}: ${scores[i]}`);
}
console.log(`Average: ${total / scores.length}`);
Output:
Score 1: 72
Score 2: 88
Score 3: 95
Score 4: 60
Score 5: 100
Average: 83
Here the counter doubles as an array index. Starting at 0 and stopping strictly before scores.length guarantees every valid index is visited exactly once, with no out-of-bounds access. This index-based pattern is the main reason to reach for a for loop instead of for...of: you get the position, not just the value.
Example 3: Using break and continue
const numbers = [3, -5, 12, 7, -2, 18, 9];
for (let i = 0; i < numbers.length; i++) {
if (numbers[i] < 0) {
continue;
}
if (numbers[i] > 10) {
console.log(`Found it: ${numbers[i]} at index ${i}`);
break;
}
console.log(`Checked ${numbers[i]}, too small`);
}
Output:
Checked 3, too small
Found it: 12 at index 2
continue skips straight to the update step for the current iteration without running the rest of the body — that’s why -5 produces no log line at all. break exits the loop entirely, which is why the loop never even looks at 7, -2, 18, or 9 once 12 is found.
Under the Hood: Step-by-Step Execution
Consider a nested loop that builds a small multiplication table:
for (let i = 1; i <= 3; i++) {
let row = '';
for (let j = 1; j <= 3; j++) {
row += `${i * j}\t`;
}
console.log(row.trim());
}
Output:
1 2 3
2 4 6
3 6 9
Here is exactly what the engine does:
- The outer loop’s initializer runs once:
i = 1. - The outer condition
i <= 3is checked — true — so the outer body starts. - Inside,
rowis created fresh for this outer iteration, and a brand-new inner loop begins: its own initializer setsj = 1, and the inner loop runs to completion (checking its condition, running its body, running its update, three full times) before control ever returns to the outer loop. - Only after the inner loop’s condition becomes false does the outer loop finish its current body (logging the row), run its own update (
i++), and recheck its own condition. - This repeats until the outer condition is false, at which point the whole statement finishes.
Every inner loop is a full, independent life cycle nested inside a single tick of the outer one — this is why nested loops multiply the number of iterations (3 outer × 3 inner = 9 body executions here) rather than adding them.
Common Mistakes
Mistake 1: Off-by-one errors
Using <= against .length instead of < reads past the end of the array:
const fruits = ['apple', 'banana', 'cherry'];
for (let i = 0; i <= fruits.length; i++) {
console.log(fruits[i]);
}
Output:
apple
banana
cherry
undefined
Valid indexes run from 0 to length - 1. Since fruits.length is 3, the condition i <= 3 lets the loop run one extra time with i = 3, which is past the last valid index and reads undefined. The fix is to use a strict <:
const fruits = ['apple', 'banana', 'cherry'];
for (let i = 0; i < fruits.length; i++) {
console.log(fruits[i]);
}
Output:
apple
banana
cherry
Mistake 2: Using var instead of let with closures
Because var is function-scoped rather than block-scoped, every closure created in the loop shares the exact same variable:
var callbacks = [];
for (var i = 0; i < 3; i++) {
callbacks.push(() => console.log(i));
}
callbacks.forEach(cb => cb());
Output:
3
3
3
By the time the callbacks actually run, the loop has already finished and i sits at its final value, 3 — every function reads the same shared variable. Switching to let gives each iteration its own fresh binding of i, so each closure remembers the value it saw at creation time:
const callbacks = [];
for (let i = 0; i < 3; i++) {
callbacks.push(() => console.log(i));
}
callbacks.forEach(cb => cb());
Output:
0
1
2
A related mistake is forgetting the update step entirely, or updating a variable the condition doesn’t actually check — either way the condition never becomes false and the loop never ends. Always double-check that your update expression moves the exact variable your condition tests.
Best Practices
- Prefer
letovervarfor loop counters so each iteration gets its own scoped binding, avoiding closure bugs. - Use a strict
<(not<=) when looping up to.length, since valid indexes stop atlength - 1. - When you don’t need the index, prefer
for...ofor array methods likemap/filter/reduce— reserve the classicforloop for cases that genuinely need index control, custom step sizes, or early exits withbreak. - Keep loop bodies short; extract complex logic into a named function so the loop itself stays easy to scan.
- Avoid mutating the array you’re iterating over (inserting or removing elements) inside the loop — it shifts indexes out from under the counter and causes skipped or repeated elements.
- Use
breakandcontinueinstead of nested boolean flags to express “stop early” or “skip this one” logic. - For performance-sensitive loops over long arrays, consider caching
.lengthin a variable before the loop if the array won’t change size, though modern engines already optimize this well in most cases.
Practice Exercises
- Write a
forloop that prints all even numbers from 2 to 20 inclusive. - Given
const temps = [68, 72, 90, 101, 77, 59];, write aforloop that logs only the temperatures greater than 75, usingcontinueto skip the rest. - Write a nested
forloop that prints a 5×5 grid of*characters, one row per line (hint: build a string in the outer loop’s body using an inner loop, similar to the multiplication table example).
Summary
- A
forloop has three header parts — initialization, condition, update — each with a distinct timing in the loop’s life cycle. - The condition is checked before every iteration, including the first; if it’s falsy immediately, the body never runs.
- Declaring the counter with
letgives each iteration a fresh binding, which matters for closures;varshares one binding across the whole loop. breakexits the loop entirely;continueskips only the rest of the current iteration.- Off-by-one errors (using
<=instead of<against.length) are the most commonfor-loop bug. - Nested loops run the inner loop to completion for every single iteration of the outer loop.
