JAVASCRIPT / LOOPS
Writing loop conditions that always terminate
Prove a loop ends before running it by naming the value that strictly moves each pass, the fixed bound it must cross, and the comparison that catches it.
What you will learn
- Name the measure, its direction, and the fixed bound before you trust a loop.
- Use i < n instead of i !== n so a large step crosses the bound instead of missing it.
- Count in integers and derive floats; never step by 0.1 and test for equality.
- When a body adds work, show the added work is strictly smaller than what it removed.
Understanding Writing loop conditions that always terminate
Whether a loop ends is not a matter of style; it is a small proof with three parts. You need a value the body changes (the measure), a guarantee that it moves in the same direction by a non-zero amount on every pass, and a fixed bound the condition compares it against. In `while (remaining > 0) remaining -= step`, the measure is `remaining`, the direction comes from `step` being positive, and the bound is 0 — and that proof only holds if you actually enforce `step > 0`. If you cannot name all three parts for a loop you wrote, you do not know that it ends.
Prefer relational comparisons to equality, because `<` and `>` fire when the measure crosses the bound while `!==` demands that it land on the bound exactly. That distinction bites hard in JavaScript, where every number is an IEEE-754 double: stepping by 0.1 walks through 0.9999999999999999 and straight past 1, and once a counter reaches 9007199254740992, adding 1 produces the same value again, so the measure freezes while the condition stays true. NaN is the mirror image — every comparison involving it is false, so a NaN measure makes `x < n` exit immediately and `x !== n` run forever.
The condition you write and the measure that proves termination are often not the same expression. `while (queue.length > 0)` proves nothing on its own, since length both grows and shrinks; that loop ends only because each pass puts back something strictly smaller than what it took out, and that shrinking value has a floor. The same gap appears in `while (i < list.length)` when the body pushes onto `list`: the bound is chasing the index, so snapshot the length in a `const` first or show that the pushes stop. Wrapping such a loop in an iteration cap unfreezes the tab but leaves the work half done, so repair the measure rather than the symptom.
// A loop ends when one value moves the same way every pass and a fixed bound catches it.
function stepsToZero(start, step) {
// rules out 0, negatives and NaN, since every comparison with NaN is false
if (!(step > 0)) throw new RangeError("step must be > 0");
let remaining = start; // the measure
let steps = 0;
while (remaining > 0) { // fixed bound 0, tested with > and not !==
remaining -= step; // strictly decreases by at least `step`
steps += 1;
}
return steps;
}
console.log(stepsToZero(10, 3));
console.log(stepsToZero(10, 10));
try {
stepsToZero(10, 0);
} catch (err) {
console.log(err.message);
}A loop terminates only when some value moves strictly one direction on every pass toward a bound that a relational condition will catch.
Worked examples
A step that skips the bound
Shows why a crossing test survives a step size that never lands on the target value.
const stops = [];
for (let i = 0; i < 10; i += 3) {
stops.push(i);
}
console.log(stops.join(" "));
console.log(10 % 3 === 0);Example explained
Line 1`i += 3` makes i take 0, 3, 6, 9, 12, so i is never equal to 10.
Line 2`i < 10` still stops the loop, because 12 crosses the bound instead of matching it.
Line 3`10 % 3 === 0` is false, and that divisibility is exactly what an `i !== 10` condition would have needed to ever finish.
Floating-point steps never land on 1
Demonstrates why an accumulated float is an unusable measure for an equality condition.
let sum = 0;
for (let k = 0; k < 10; k += 1) {
sum += 0.1;
}
console.log(sum);
console.log(sum === 1);
let derived = 0;
for (let k = 0; k <= 10; k += 1) {
derived = k / 10;
}
console.log(derived === 1);Example explained
Line 10.1 has no exact double representation, so each `sum += 0.1` adds a slightly wrong value and the error accumulates.
Line 2After ten additions sum is 0.9999999999999999, so a `sum !== 1` condition stays true and the next step jumps past 1 to 1.0999999999999999.
Line 3`k / 10` rounds once instead of ten times, and 10 / 10 is exactly 1, so an integer counter keeps the bound reachable.
When the condition is not the measure
A worklist whose length rises and falls, yet terminates because the requeued value shrinks.
const queue = [3];
const drained = [];
while (queue.length > 0) {
const n = queue.shift();
drained.push(n);
if (n > 0) queue.push(n - 1);
}
console.log(drained.join(" "));
console.log(queue.length);Example explained
Line 1`queue.length > 0` is the condition, but a pass that shifts one item and pushes one leaves length unchanged, so length is not the reason the loop ends.
Line 2`n - 1` is the real measure: what goes back in is always strictly smaller than what came out.
Line 3`if (n > 0)` gives that measure a floor, so after 3, 2, 1, 0 nothing is pushed and the queue drains.
Line 4Changing the push to `queue.push(n)` keeps length at 1 forever and the loop never returns.
Important notes
Validate a numeric measure with `Number.isFinite` before the loop: NaN makes `x < n` false so the body never runs, and makes `x !== n` true so the loop never ends.
Above Number.MAX_SAFE_INTEGER the measure can stop moving: 9007199254740992 + 1 evaluates to 9007199254740992, so a counter aiming at a larger bound sticks there.
Common mistakes
Writing `i !== limit` with a step that does not divide the distance: i goes 9 then 12, equality is never satisfied, and the loop runs forever.
Stepping a float, as in `for (let t = 0; t !== 1; t += 0.1)`: the running total is 0.9999999999999999 and then 1.0999999999999999, so the condition never turns false and the page hangs.
Re-reading a growing bound, as in `while (i < list.length) { ...; list.push(x); }`: length rises at least as fast as i, so the gap never closes.
Try it yourself
Change, predict, then run
In the browser console, write `stepsPast(target, step)` that starts at 0, adds `step` until it reaches or passes `target`, and returns how many additions it took. Make `stepsPast(10, 3)` return 4 and make `stepsPast(10, 0)` throw a RangeError instead of hanging.
Open the JavaScript workspaceCheck your understanding
A worklist loop reads `while (queue.length > 0) { const job = queue.shift(); if (job.retries > 0) queue.push({ retries: job.retries - 1 }); }`. What actually guarantees it ends?
- Each pass puts back a job whose `retries` is strictly smaller, and `retries` cannot go below 0.
- `shift()` removes an element, so `queue.length` is smaller on every pass.
- The condition uses `>` rather than `!==`, so it must eventually become false.
- An array cannot grow past its maximum length, so the queue has to empty.
Show answer
Length is not monotone here: a pass that requeues removes one job and adds one, leaving length unchanged, which is why the `shift()` argument fails. Termination rests on `retries`, a non-negative integer that drops by one each time work is added back, so after finitely many passes nothing is requeued and the queue drains. Preferring `>` to `!==` is good practice but a relational operator cannot by itself force a condition to become false.