Repeating Work Without Repeating Code
A loop repeats a block of code. Without one, printing a hundred names means writing a hundred lines; with one it means three. But repetition is only half the reason loops matter. The other half is that you almost never know in advance how many items there will be — an array coming back from a server might hold two rows or two thousand, and one loop handles both without a single change.
The classic for loop packs three separate things into its header, separated by semicolons: an initialiser that runs once before anything else, a condition checked before every pass, and an update that runs after every pass. Read it in that order and it stops being mysterious. for (let i = 0; i < 5; i++) means: start i at 0, keep going while i is under 5, and add one to i at the end of each pass.
Two habits are worth building right now. Declare the counter with let and never with var — the reason arrives later in this lesson and it is not a style preference. And compare with < against length, never <=: an array of length 3 has indexes 0, 1 and 2, so i <= arr.length reads one element past the end and hands you undefined. That single character is the most common off-by-one error in the language.
All three parts of the header are technically optional. for (;;) is a legal infinite loop, and you can drop the update if you change the counter inside the body instead. Both are valid and both make the loop harder to read, so unless you have a specific reason, keep all three parts exactly where a reader expects to find them.
// initialiser; condition; update
for (let i = 0; i < 5; i++) {
console.log(i); // 0 1 2 3 4
}
// Walking an array by index
const subjects = ['Maths', 'Physics', 'Chemistry'];
for (let i = 0; i < subjects.length; i++) {
console.log(`${i + 1}. ${subjects[i]}`);
}
// Counting down
for (let i = 3; i > 0; i--) {
console.log(i); // 3 2 1
}
// Stepping by more than one
for (let i = 0; i <= 20; i += 5) {
console.log(i); // 0 5 10 15 20
}
// Off by one: <= runs one pass too many
for (let i = 0; i <= subjects.length; i++) {
console.log(subjects[i]); // the last line prints undefined
} while and do...while
A while loop is the same machinery with only the condition left in the header. Reach for it when you do not know how many passes you need: consuming a queue until it is empty, retrying until something succeeds, or reading until you reach the end of something. If you do know the count in advance, a for loop states that fact more clearly and keeps the counter logic in one place.
do...while checks its condition after the body rather than before, which guarantees the body runs at least once. That is exactly right for "ask, then check the answer" logic — prompt for input, validate it, and repeat only if it was unacceptable. It is the rarest of the loop forms, but when it fits, nothing else reads as naturally.
Every while loop needs something inside it that eventually makes the condition false. Forgetting to update that variable is the classic way to freeze a browser tab, and the reason it freezes is worth understanding: JavaScript on a page runs on a single thread. While your loop spins, nothing else can run — no rendering, no clicks, no timers, no network callbacks. The tab stops responding completely until the browser offers to kill it.
When a loop's exit depends on something you do not fully control — a retry that might never succeed — add a maximum attempt count as a second condition. A loop that can only run a fixed number of times cannot hang, whatever else goes wrong around it. This costs one variable and removes an entire category of production incident.
// while: the number of passes is not known up front
const queue = ['job-1', 'job-2', 'job-3'];
while (queue.length > 0) {
console.log('Processing', queue.shift());
}
// do...while: the body always runs at least once
let attempt = 0;
do {
attempt++;
console.log('Attempt', attempt);
} while (attempt < 3);
// A safety limit, so the loop cannot hang
let tries = 0;
let done = false;
while (!done && tries < 5) {
tries++;
done = tries === 3; // stand-in for real work
}
console.log('Finished after', tries, 'tries');
// This would freeze the tab - i never changes
// let i = 0;
// while (i < 5) { console.log(i); } - If a page ever goes completely unresponsive while you are testing, an accidental infinite loop is the first suspect. The console will not help, because the console needs the same thread your loop is holding — you have to close the tab and fix the exit condition.
break, continue and Labels
break leaves the loop immediately: nothing after it in the body runs and there are no further passes. continue skips the rest of the current pass and jumps straight to the next one. Both work in for, while, do...while and for...of.
break is what you use for a search — walk the list and stop the moment you find what you were looking for. On a large list that is not a micro-optimisation: there is no point checking the remaining nine thousand rows once you already have the answer. continue is a filter — skip the entries that do not qualify and keep the loop body flat, instead of wrapping everything below it in an if.
Inside nested loops, break leaves only the inner loop. To leave both, put a label before the outer loop and write break outer;. Labels are perfectly legal and rarely used, and their appearance is usually a hint that the inner loop wants to be its own function — because a return from a function leaves everything at once and needs no label at all.
One limitation matters more than the rest: neither break nor continue works inside forEach. A return in a forEach callback only ends that single call, and the loop carries on to the next element regardless. If you need to stop early, use a real loop, or one of the array methods built for it — find, some and every all stop as soon as they can.
const marks = [45, 82, 30, 91, 67];
// break: stop as soon as you have the answer
for (const m of marks) {
if (m > 80) {
console.log('First high score:', m);
break;
}
}
// continue: skip what does not qualify, keep the body flat
for (const m of marks) {
if (m < 40) continue;
console.log('Passed with', m);
}
// Nested loops: a label lets break leave both
outer:
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
if (i * j === 2) break outer;
console.log(i, j);
}
}
// forEach cannot be stopped - return ends one call, not the loop
marks.forEach(m => {
if (m > 80) return; // skips this element only
console.log('still running for', m);
}); breakinside aswitchthat sits inside a loop breaks out of the switch, not the loop. If you meant the loop, you need a label — or, better, move the switch into a function and return from it.
for...of, for...in and Iterating Properly
for...of walks the values of anything iterable — arrays, strings, Map, Set, and the collections the DOM hands you. It should be your default when you want each item and do not need its position, because there is no counter to get wrong and no length comparison to get backwards.
When you do need the index as well, arr.entries() produces index-and-value pairs and destructuring unpacks them on the spot: for (const [i, item] of arr.entries()). That is tidier than keeping a manual counter alongside a for...of, and it cannot drift out of step with the loop.
for...in is a different loop with a confusingly similar name, and it walks keys rather than values. On an object that is what you want, although Object.keys is safer because for...in also visits inherited enumerable properties. On an array it hands you the indexes as strings — '0', '1', '2' — so i + 1 produces '01' instead of 1, and the bug looks like a formatting problem rather than a loop problem.
for...of also works on a string, giving one character per pass, and it handles characters built from two code units — emoji, and several scripts — correctly, where looping over str[i] by number splits them in half. That is a small detail that saves real confusion the first time somebody pastes an emoji into your form.
const subjects = ['Maths', 'Physics', 'Chemistry'];
// Values, with no counter to get wrong
for (const subject of subjects) {
console.log(subject);
}
// Index and value together
for (const [i, subject] of subjects.entries()) {
console.log(`${i + 1}. ${subject}`);
}
// for...in on an array gives STRING keys - almost never what you want
for (const i in subjects) {
console.log(i + 1); // '01', '11', '21' <- string joining, not addition
}
// for...in on an object is the intended use
const marks = { maths: 82, physics: 74 };
for (const subject in marks) {
console.log(subject, marks[subject]);
}
// Strings are iterable too
for (const ch of 'JS') {
console.log(ch); // 'J' then 'S'
} for (const x of ...)is fine even thoughxchanges each pass. A fresh binding is created for every iteration, soconstis honoured within each one — and it stops you accidentally reassigning the loop variable inside the body.
Loop Gotchas That Actually Bite
The first is the one interviewers ask about most. Declare the counter with var and put an asynchronous callback inside the loop, and every callback shares one single variable. By the time those callbacks run, the loop has already finished, so each of them sees the final value — you get 3, 3, 3 instead of 0, 1, 2. let creates a fresh binding for each iteration, so each callback captures its own copy. This is the closure behaviour from Lesson 4, and it is the practical reason to write let in every loop header.
The second is modifying an array while you are looping over it. Removing an element shifts everything after it down one position, but your counter still moves up, so you skip the element immediately after each removal. Run it on [1, 2, 2, 3] intending to remove every 2, and one 2 survives. Either loop backwards, so removals only affect indexes you have already passed, or — much better — build a new array with filter and leave the original alone.
The third belongs to Lesson 22 but earns its place on this list now: forEach ignores async. An async callback returns a promise, forEach throws that promise away, and so the loop finishes instantly while the work is still in flight. Every line after the loop then runs on data that has not arrived. A for...of loop with await inside does wait, one item at a time, which is what you almost always meant.
The fourth is cheap to avoid: rebuilding something expensive on every pass. Constructing a formatter, querying the page, or compiling a regular expression inside a loop body does the same work once per iteration for no benefit. Move anything that does not change out above the loop. To be clear, this is about genuine work — arr.length on a plain array is not expensive, and caching it is folklore rather than optimisation.
// 1. var shares one binding; let makes a fresh one each pass
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log('var:', i), 0); // 3, 3, 3
}
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log('let:', j), 0); // 0, 1, 2
}
// 2. Removing while looping forwards skips elements
const nums = [1, 2, 2, 3];
for (let k = 0; k < nums.length; k++) {
if (nums[k] === 2) nums.splice(k, 1);
}
console.log(nums); // [1, 2, 3] <- one 2 survived
// Loop backwards, or simply use filter
console.log([1, 2, 2, 3].filter(n => n !== 2)); // [1, 3]
// 4. Do not rebuild expensive things inside the loop
const rows = [125000, 4500, 99];
// Wasteful: a new formatter on every pass
for (const r of rows) {
const f = new Intl.NumberFormat('en-IN');
console.log(f.format(r));
}
// Better: build it once, above the loop
const formatter = new Intl.NumberFormat('en-IN');
for (const r of rows) {
console.log(formatter.format(r));
} - The
varexample is worth typing out and running yourself. Being able to say why it prints 3, 3, 3 — one shared binding, callbacks running after the loop ended — is what the interview question is actually testing.
Choosing the Right Loop
In practice you will use three of these constantly and the rest occasionally. for...of when you want the values. A counting for loop when you need indexes, or need to step or count in an unusual way. while when the number of passes depends on a condition rather than a count. Everything else is a special case you will recognise when it arrives.
There is also a whole family of array methods — map, filter, reduce, find, some, every — that replace loops entirely for the most common tasks, and the next lesson covers them properly. Prefer them wherever they fit, because const passed = marks.filter(m => m >= 40) announces its intent in a way that a five-line loop with a counter, an empty array and a push never can.
On performance: for anything under a few thousand items, every loop form here is fast enough that the difference is invisible to a user, and picking the clearest one is the correct engineering decision. Where loops genuinely become slow is when the body does something expensive — touching the page inside the loop is the usual culprit — and Lesson 29 deals with that properly.
- Count known, index needed — a counting
forloop - Just need each value —
for...of - Count unknown, depends on a condition —
while - Body must run at least once —
do...while - Keys of an object —
Object.keys()withfor...of, orfor...inwith a guard - Transform, filter or total an array — the methods in Lesson 10, not a loop
- Need
awaitinside the body —for...of, neverforEach - Need to stop early — anything except
forEach
- If a loop body grows past about ten lines, move it into a named function and call that from the loop. The loop then reads as a single sentence — "for each student, print the report card" — and the function can be tested on its own without the loop.
