JAVASCRIPT / LOOPS
Infinite loops and how to escape them
Diagnose why a JavaScript loop never exits, understand why it freezes the whole page, and add iteration guards that turn a hang into a readable error.
What you will learn
- Name the value a loop's exit test reads, then check the body actually moves it
- Explain why a spinning loop blocks timers, clicks and repaints on one thread
- Escape a hang from outside: kill the tab, or Ctrl+C the Node process
- Add a pass budget that throws, turning a silent hang into an error with state
Understanding Infinite loops and how to escape them
An infinite loop is not a slow loop. It is a loop whose exit test can never become false given what the body does, so the useful question is never "how long will this take" but "which value does the condition read, and what in the body moves it". `while (queue.length > 0)` reads a length; if the body only peeks at `queue[0]` and never removes anything, the length is frozen and the loop is already infinite on its first pass. `while (true)` is not the dangerous shape here: a loop with a reachable `break` always has a way out, while a loop with a plausible-looking condition may have none.
Once a runaway loop starts, nothing inside the page can stop it, because JavaScript finishes the job it is running before it takes the next one from the queue. Your click handler, your `setTimeout` callback and the browser's next repaint are all queued behind the loop, which is why the tab goes unresponsive and why output you expected to watch scroll by may never appear. The escape has to come from outside that thread: Chrome's "Page unresponsive" dialog, closing the tab, the DevTools pause button (which can usually interrupt the loop and show the offending line), or Ctrl+C for a Node script. A loop whose body `await`s something does hand the thread back between passes and keeps the page alive, but it is still never going to finish.
Because you cannot reliably interrupt a runaway loop from the inside, the practical defence is to make the failure loud instead of silent. Keep a pass counter and `throw` when it crosses a ceiling far above any legitimate run, and put the state the condition depends on into the message. You then get an error, a line number and a snapshot of what the loop believed, instead of a frozen tab and no information at all. Reach for this on loops whose exit depends on data you do not control — queues, parsers, retry loops, linked structures that might contain a cycle — and treat a trip as a bug to fix, never as the normal way the loop ends.
function drainQueue(queue) {
const done = [];
let passes = 0;
while (queue.length > 0) {
if (++passes > 1000) {
throw new Error(
`runaway loop: ${queue.length} still queued after ${passes - 1} passes`
);
}
done.push(queue[0]); // bug: reads the first job but never removes it
}
return done;
}
try {
drainQueue(['render', 'save']);
} catch (err) {
console.log(err.message);
console.log('guarded, so the bug is an error instead of a frozen tab');
}
function drainFixed(queue) {
const done = [];
while (queue.length > 0) {
done.push(queue.shift()); // the value the condition reads now shrinks
}
return done;
}
console.log(drainFixed(['render', 'save']).join(', '));A loop hangs when nothing in its body can ever satisfy the exit test, and because JavaScript runs one job to completion on a single thread, the rescue must either be designed in before the loop starts or come from outside the process.
Worked examples
A comparison that never matches
Shows why testing an accumulated float with !== produces a loop that steps straight past its target.
let x = 0;
let steps = 0;
// the steps guard is the only reason this terminates
while (x !== 1 && steps < 15) {
x += 0.1;
steps++;
}
console.log('guard stopped it:', steps === 15, '| x =', x);
let last = 0;
for (let tenths = 1; tenths <= 10; tenths++) {
last = tenths / 10;
}
console.log('integer counting hits 1 exactly:', last === 1);Example explained
Line 1`x += 0.1` accumulates rounding error: the values go 0.8999999999999999, then 0.9999999999999999, then 1.0999999999999999, so 1 is skipped.
Line 2`x !== 1` is therefore true on every pass, and only `steps < 15` ends the loop.
Line 3Counting `tenths` as whole numbers keeps each step exact, and `10 / 10` is exactly 1, so `===` is safe there.
Line 4General rule: loop with an integer counter and divide at the end, rather than testing an accumulating float for equality.
Why the tab stops responding
Proves that a queued callback cannot run while a loop is spinning, so no in-page rescue is possible.
let ticked = false;
setTimeout(() => {
ticked = true;
console.log('timer callback ran (after the loop returned)');
}, 0);
const start = Date.now();
while (Date.now() - start < 300) {
// stand-in for a runaway loop: it holds the thread
}
console.log('loop is done. did the 0 ms timer get a turn?', ticked);Example explained
Line 1`setTimeout(..., 0)` only queues the callback; it cannot interrupt code that is already running.
Line 2The `while` line keeps the single thread for 300 ms, so the callback waits long after its delay expired.
Line 3`ticked` is still false at the log, meaning no callback, event handler or repaint slipped in mid-loop.
Line 4A Stop button in your own page fails for the same reason: the click sits in the same queue as everything else.
A length that keeps moving
Demonstrates a loop whose end condition is pushed further away by its own body.
const items = ['a', 'b'];
let iterations = 0;
for (let i = 0; i < items.length; i++) {
if (++iterations > 5) {
console.log('aborting: length is now', items.length);
break;
}
items.push(items[i].toUpperCase());
}
console.log(items.join(','), 'after', iterations, 'iterations');Example explained
Line 1`i < items.length` is re-evaluated on every pass, so it reads the new, longer length each time.
Line 2Each pass consumes one item and appends one, so `i` gains 1 and the end gains 1: the gap never closes.
Line 3The `iterations > 5` check is the escape hatch; without it the array grows until the tab runs out of memory.
Line 4Snapshotting `const end = items.length` before the loop, or pushing into a second array, makes the bound fixed.
Important notes
`while (true)` is not automatically a bug. With a reachable `break` or `return` it is the normal way to write a loop whose exit is decided partway through the body; the bug is any loop whose exit is unreachable, however innocent its condition looks.
Ctrl+C ends a Node script stuck in a synchronous loop because the operating system terminates the process. If the code registered its own `SIGINT` listener, that handler is queued like any other callback and never runs, so you need `kill -9` on the process instead.
Common mistakes
Using `!==` against a float that is built up by repeated addition, as in `while (x !== 1) x += 0.1`: the value jumps from 0.9999999999999999 to 1.0999999999999999, so the condition is never false and the loop runs until something kills the process.
Planning an in-page rescue, such as a Stop button or a `setTimeout` that sets a flag the loop checks: those callbacks are queued behind the loop and never run, so the tab freezes anyway and any unsaved page state is lost.
Logging from inside the suspect loop to see what is happening: a runaway loop emits millions of lines and locks up DevTools too, so you lose the tool you were debugging with. Throw from a guard and log once, outside.
Try it yourself
Change, predict, then run
In the browser console, build three linked nodes where the last one's `next` points back at the first, then walk them with `while (node) { node = node.next; }`. Add a hop counter that throws past 50 and confirm you get an error message rather than a frozen tab.
Open the JavaScript workspaceCheck your understanding
A page hangs in `while (queue.length) { ... }`. Before the loop you add `let stop = false; setTimeout(() => { stop = true; }, 100);` and inside it `if (stop) break;`. Why does the page still hang forever?
- `stop` is copied into the loop's scope, so assignments made by the callback are not visible inside it.
- The loop would have to be declared `async` before it can observe a variable that a timer changed.
- The callback cannot run until the loop returns, and the loop only returns once the callback has run.
- A 100 ms delay is too short; pending timers are discarded while the thread is busy.
Show answer
JavaScript runs each job to completion, so the callback that would set `stop` waits in the queue behind the loop that is waiting for it — a standstill only something outside that thread can break. The first option is tempting because closures look like snapshots, but `stop` is a shared binding: if the callback ever got a turn, the loop would see `true` on its next pass.