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 <= 3 is checked — true — so the outer body starts.
  • Inside, row is created fresh for this outer iteration, and a brand-new inner loop begins: its own initializer sets j = 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 let over var for 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 at length - 1.
  • When you don’t need the index, prefer for...of or array methods like map/filter/reduce — reserve the classic for loop for cases that genuinely need index control, custom step sizes, or early exits with break.
  • 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 break and continue instead of nested boolean flags to express “stop early” or “skip this one” logic.
  • For performance-sensitive loops over long arrays, consider caching .length in 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 for loop that prints all even numbers from 2 to 20 inclusive.
  • Given const temps = [68, 72, 90, 101, 77, 59];, write a for loop that logs only the temperatures greater than 75, using continue to skip the rest.
  • Write a nested for loop 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 for loop 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 let gives each iteration a fresh binding, which matters for closures; var shares one binding across the whole loop.
  • break exits the loop entirely; continue skips only the rest of the current iteration.
  • Off-by-one errors (using <= instead of < against .length) are the most common for-loop bug.
  • Nested loops run the inner loop to completion for every single iteration of the outer loop.