skip to content

What happens if you `splice` elements out of an array from inside its own `forEach` callback, and how does a `for...of` loop over the same array behave differently?

level: middleimportance: should knowfreq 42%

answer

  1. the array is live under both loops
  2. no concurrent-modification error exists
  3. one caches length, one re-reads it
  4. removal shifts the rest down
  5. push plus for...of never ends

basics

~20 s

forEach reads the array's length once before it starts but checks each index as it goes, so removing an element shifts the rest down and one gets skipped per removal; appended elements are never visited. for...of re-reads length on every step, so it does see appends — which makes a loop that pushes run forever.

solid answer

~50 s

Both loops read the live array, so mutating it mid-flight changes what you visit — but they change differently. `forEach` captures `length` once before the first call, then for each index checks whether that index still exists. Splice out the current element and everything after it shifts down one, so the next index you visit skips an element; the last iterations find nothing there and are silently skipped. Elements you `push` during the loop are never visited, because the cached `length` already ruled them out. `for...of` uses the array iterator, which re-reads `length` on every `next()` — so removals still cause skipping and an early finish, but appends **are** picked up, and a loop that pushes on every pass never terminates. Neither is a bug in the language; the fix is to not mutate what you are iterating. Iterate a copy (`for (const x of [...arr])`), build a new array with `filter`, or walk a plain index loop backwards when you must remove in place.

code

javascript · 13 lines
javascript
const a = ['x', 'y', 'z'];
a.forEach((v, i) => {
  console.log('forEach visit', v);
  if (v === 'x') a.splice(i, 1);
});
console.log(a); // ['y', 'z'] -- 'y' was never visited

const b = ['x', 'y', 'z'];
for (const v of [...b]) {
  console.log('copy visit', v); // all three
  if (v === 'x') b.splice(b.indexOf(v), 1);
}
console.log(b); // ['y', 'z'], every element seen

go deeper

for a junior

Know that removing items from an array while looping over it skips elements, and that the safe habit is to filter into a new array instead of splicing in place.

for a middle

Explain the mechanical difference: forEach caches length once and checks index presence, while the array iterator re-reads length each step — hence skipping in both, and a non-terminating loop only with for...of plus push.

for a senior

Recognise the symptom in review — a list that comes out almost correct, failing only when two matches are adjacent — and state the in-place fix (reverse index loop) versus the preferred non-mutating rewrite.

for a principal

Set the rule that shared collections are transformed into new values rather than mutated in place, so aliasing and iteration order stop being correctness concerns anywhere in the codebase.

## Both loops read the live array JavaScript arrays give you no concurrent-modification guard. There is no exception, no snapshot, no "the collection changed" error — the loops just keep reading the array as it is at that instant. The two constructs differ in *when* they consult `length`, and that single difference produces two distinct failure modes. ## forEach: length cached, presence checked `Array.prototype.forEach` reads `length` once at the start. Then for each index from `0` to that cached `length - 1`, it checks whether the index is actually present, and only then calls your callback. Removing during the loop: ```js const a = ['x', 'y', 'z']; a.forEach((v, i) => { console.log('visit', v); if (v === 'x') a.splice(i, 1); }); // visit x, visit z // a is now ['y', 'z'] — 'y' was never visited ``` At `i = 0` the callback sees `'x'` and splices it out. `'y'` slides down into index 0, `'z'` into index 1. The loop moves to `i = 1` and finds `'z'` there — `'y'` was skipped entirely. At `i = 2` nothing is present, so that iteration is skipped silently. This is the classic "filter by splicing" bug: run it over a list where every other element matches and you remove exactly half of what you meant to. Appending during the loop: ```js const b = [1]; b.forEach((n) => { if (n < 3) b.push(n + 1); }); console.log(b); // [1, 2] — the pushed 2 is never visited ``` The cached length was 1, so the loop is over after one call. No infinite loop, but also no processing of what you added. Holes behave the same way as removals: `forEach` skips indices that are not present, so `[1, , 3].forEach(...)` invokes the callback twice, not three times. ## for...of: length re-read every step `for...of` over an array uses the built-in array iterator. Each `next()` compares the current index against the array's `length` **as it is right now**, and if the index is in range it reads that index. Removing: ```js const c = ['x', 'y', 'z']; for (const v of c) { if (v === 'x') c.splice(0, 1); console.log('visit', v); } // visit x, visit z — same skip, and the loop ends early ``` Same shift-and-skip, and because `length` shrank the loop also stops sooner than you expect. Appending: ```js const d = [1]; for (const n of d) { d.push(n + 1); } // never terminates ``` This is the sharp difference. The iterator asks again each step, `length` keeps growing, and the loop runs until the process dies of memory exhaustion. A `forEach` written the same way just quietly does one pass. Also note `for...of` *reads* holes rather than skipping them, so `for (const v of [1, , 3])` yields `1, undefined, 3`. ## Doing it correctly **Iterate a copy.** Cheapest to read, and correct for both loops: ```js for (const item of [...items]) { if (shouldRemove(item)) items.splice(items.indexOf(item), 1); } ``` **Build a new array instead of mutating.** Usually the right answer, and it says what you mean: ```js const kept = items.filter((item) => !shouldRemove(item)); ``` **Walk backwards with an index loop.** When you must remove in place — because other code holds a reference to that exact array — going backwards means a removal only shifts elements you have already passed: ```js for (let i = items.length - 1; i >= 0; i--) { if (shouldRemove(items[i])) items.splice(i, 1); } ``` **Collect then remove.** Gather the indices or items in the loop, apply the changes afterwards. Verbose but obvious under review. ## Why this shows up in interviews It is a bug with no error message. The array ends up *nearly* right, so tests written on small fixtures with a single match pass, and the defect only bites on data where two matching elements are adjacent. Being able to explain the cached-length-versus-re-read distinction shows you know these constructs mechanically rather than as interchangeable syntax — and the practical takeaway, "don't mutate what you're iterating; produce a new array," is the kind of rule a reviewer can actually apply.

  • Why does a for...of loop that pushes to the same array never terminate, while the same code in forEach stops after one pass?
    The array iterator compares its index against the array's current `length` on every step, so each push extends the range it will cover. `forEach` reads `length` once before the first call and never consults it again, so anything appended is outside the range it decided on and the loop simply ends.
  • If you must remove elements in place, why does iterating backwards work?
    Removing at index `i` shifts only the elements after `i` down by one — and going from the end toward zero, everything after `i` has already been visited. Nothing you still need to look at moves, so no element is skipped and no index is visited twice. Forward iteration has the opposite property, which is exactly why it skips.
  • How do the two loops differ over a sparse array like [1, , 3]?
    `forEach` checks whether each index is actually present and skips holes, so the callback runs twice with 1 and 3. `for...of` reads each index in range, so a hole yields `undefined` and you get three values. It is a small difference that matters when the data has genuine gaps rather than explicit undefined entries.

saying these in an interview costs you the question

  • Expects an error when the array changes mid-loop
  • Thinks forEach snapshots the array contents
  • Says splicing forward removes every match
  • Believes both loops cache length the same way
  • Claims for...of ignores elements added during iteration

context