Knowledge Base/concepts/Off-by-One Errors in JavaScript Loops

Off-by-One Errors in JavaScript Loops

Off-by-one errors in JavaScript loops are boundary mistakes, not mysterious loop curses. The loop usually starts one step too early, stops one step too late, or uses a count as if it were an index. JavaScript does exactly what you asked. The annoying part is that you asked for one extra trip around the loop.

The fix is to separate two ideas that beginners often merge: length is a count, index is a position.

Contents

The core boundary rule

Arrays are zero-indexed:

const lessons = ["Variables", "Operators", "Strings"];

console.log(lessons[0]); // "Variables"
console.log(lessons[1]); // "Operators"
console.log(lessons[2]); // "Strings"

The length is 3, but the last valid index is 2.

console.log(lessons.length); // 3
console.log(lessons[lessons.length]); // undefined
console.log(lessons[lessons.length - 1]); // "Strings"

That gives the normal array loop shape:

for (let index = 0; index < lessons.length; index += 1) {
  console.log(lessons[index]);
}

Read the condition out loud: keep going while the index is less than the length. Not less than or equal to the length. Equal to the length is already one position past the end.

The classic array mistake

This is the off-by-one bug every JavaScript learner meets eventually:

const lessonTitles = ["Variables", "Operators", "Strings"];
const copiedTitles = [];

for (let i = 0; i <= lessonTitles.length; i += 1) {
  copiedTitles.push(lessonTitles[i]);
}

console.log(copiedTitles);

The output is:

["Variables", "Operators", "Strings", undefined]

The loop condition allows i to become 3. But lessonTitles[3] does not exist.

The fix is:

const lessonTitles = ["Variables", "Operators", "Strings"];
const copiedTitles = [];

for (let i = 0; i < lessonTitles.length; i += 1) {
  copiedTitles.push(lessonTitles[i]);
}

console.log(copiedTitles); // ["Variables", "Operators", "Strings"]

You can practice this exact fix in the Off-by-One Bugs exercise.

The broader JavaScript for Loops guide covers the full loop syntax. This article stays on the boundary bug because that is where the mistake usually hides.

Trace the loop instead of guessing

When a boundary feels slippery, write down the values the loop will actually use.

For this broken loop:

for (let i = 0; i <= lessonTitles.length; i += 1) {
  console.log(i, lessonTitles[i]);
}

The trace is:

iConditionValue
00 <= 3"Variables"
11 <= 3"Operators"
22 <= 3"Strings"
33 <= 3undefined
44 <= 3loop stops

The table makes the bug boring, which is exactly what you want. The bad iteration is visible.

You can also prove the boundary with a tiny assertion:

const lessonTitles = ["Variables", "Operators", "Strings"];
const copiedTitles = [];

for (let i = 0; i < lessonTitles.length; i += 1) {
  copiedTitles.push(lessonTitles[i]);
}

console.assert(copiedTitles.length === lessonTitles.length);
console.assert(!copiedTitles.includes(undefined));

Those assertions are not fancy. They protect the two things this loop promises: copy every item, and do not copy past the end.

Inclusive counts are different from array indexes

Sometimes <= is correct. Counting from 1 through 5 is inclusive:

for (let count = 1; count <= 5; count += 1) {
  console.log(count);
}

The output is:

1
2
3
4
5

That loop is not indexing an array. It is counting through a human range where both ends are included.

Compare that with an array loop:

const numbers = [1, 2, 3, 4, 5];

for (let index = 0; index < numbers.length; index += 1) {
  console.log(numbers[index]);
}

Both loops print five numbers, but the boundaries are different:

TaskStartStop conditionWhy
Count 1 through 51count <= 55 is part of the range
Read every array item0index < numbers.lengthnumbers.length is past the last index

The operator is not the important part by itself. The meaning of the boundary is.

Empty arrays expose bad boundaries

A good array loop should run zero times for an empty array.

const names = [];

for (let i = 0; i < names.length; i += 1) {
  console.log(names[i]);
}

The first condition check is 0 < 0, which is false, so the loop body never runs.

This broken version runs once:

const names = [];

for (let i = 0; i <= names.length; i += 1) {
  console.log(names[i]);
}

The first condition check is 0 <= 0, which is true, so the loop logs undefined.

Empty inputs are excellent bug detectors. If a loop reads from an empty array, something about the boundary is probably wrong.

Slicing ranges use a different convention

Off-by-one errors also appear when ranges have a start and an end.

JavaScript's slice(start, end) includes the start index and excludes the end index:

const letters = ["a", "b", "c", "d", "e"];

console.log(letters.slice(1, 4)); // ["b", "c", "d"]

Index 1 is included. Index 4 is not.

That half-open range, written as [start, end), is common in programming because the length is easy to calculate:

const start = 1;
const end = 4;

console.log(end - start); // 3 items

Manual loops can use the same convention:

const letters = ["a", "b", "c", "d", "e"];
const selected = [];
const start = 1;
const end = 4;

for (let i = start; i < end; i += 1) {
  selected.push(letters[i]);
}

console.log(selected); // ["b", "c", "d"]

The loop condition is i < end because end is the first index after the range, not the last index inside it.

When for...of avoids the problem

If you do not need the index, do not force yourself to manage one.

const lessonTitles = ["Variables", "Operators", "Strings"];

for (const title of lessonTitles) {
  console.log(title);
}

A for...of loop reads values directly. There is no 0, no length - 1, and no <= waiting to step on your shoelace.

Use a traditional for loop when the index is part of the task:

const lessonTitles = ["Variables", "Operators", "Strings"];

for (let i = 0; i < lessonTitles.length; i += 1) {
  console.log(`${i + 1}. ${lessonTitles[i]}`);
}

The index matters here because the code is building numbered labels. For value-only work, the for...of loop guide is often the cleaner tool.

How to debug an off-by-one loop

Start with the loop's promise. What should happen?

const prices = [10, 15, 20];
let total = 0;

for (let i = 0; i < prices.length; i += 1) {
  total += prices[i];
}

This loop promises to visit every real index in prices exactly once.

Check the four boundary facts:

QuestionFor prices
First valid index0
Last valid indexprices.length - 1
First invalid index after the arrayprices.length
Should an empty array run the body?No

Then check the loop against those facts:

for (let i = 0; i < prices.length; i += 1) {
  total += prices[i];
}
  • Starts at 0: good.
  • Runs while i < prices.length: stops before the first invalid index.
  • Increments by 1: visits each index once.
  • Empty array gives 0 < 0: body does not run.

That is the whole debugging process. You are not asking whether the loop looks familiar. You are asking whether the first value, last value, and first invalid value line up.

For loop variables, let vs const in JavaScript explains why a traditional counter uses let while a for...of item can usually use const.

Key Takeaways

  • Array length is a count. The last valid array index is length - 1.
  • Use i < array.length for normal array indexing loops.
  • Use <= for inclusive human ranges, not for reading through array indexes.
  • Test empty arrays when debugging loop boundaries.
  • Prefer for...of when you need values but not indexes.
Share this article:
javascriptfundamentalsloopsdebugging