Skipped Sessions: Mutating an Array While Looping
A nightly cleanup job receives an array of 9 session records in creation order. A record is expired when expiresAt is in the past. On a sample run 5 records are expired, but the job logs only 3 deletions and 2 expired sessions survive; they are always adjacent pairs in the array. The loop is for (let i = 0; i < sessions.length; i++) { if (isExpired(sessions[i])) sessions.splice(i, 1); }. A second symptom: a different cleanup pass uses a while loop over a copy and occasionally hangs in staging but never locally, and CI kills the job after 30 seconds. The team cannot change the array in place because the same array reference is rendered in an admin view that must keep showing the original 9 rows until the job finishes. The runtime is Node 18; arrays hold at most 20,000 records in production.
1. Why do exactly 2 expired records survive, and why are they adjacent to removed ones?
Read the worked answer
When splice(i, 1) removes element i, every later element shifts down one index, but the for loop still increments i. The element that was at i+1 is now at i and is never examined, so an expired record immediately following a removed record survives. That is why survivors appear in adjacent pairs: each removal can shelter exactly one neighbour. With 9 records and 5 expired, the job logged 3 removals, meaning 3 removals sheltered a neighbour and 2 of the sheltered neighbours were themselves expired - consistent with the observed 2 survivors. The same skip occurs with for...of and forEach when you mutate during iteration. Note the loop condition re-reads length each pass, which is why it terminates and why the count stops at 3 rather than running past the end.
2. Give a fix that keeps the admin view's original array intact, and explain the trade-offs.
Read the worked answer
Use filter, which does not mutate and returns a new array: const kept = sessions.filter(s => !isExpired(s));. The admin view keeps the original reference and still sees all 9 rows until you reassign. If the job must mutate the shared array in place, iterate backwards: for (let i = sessions.length - 1; i >= 0; i--) if (isExpired(sessions[i])) sessions.splice(i, 1); - removing from the end never shifts an unexamined element. A third option, collecting indices then removing them in descending order, works but is noisier. Separate the decision from the mutation: compute the expired set once, log that count, then mutate; otherwise the log reflects a partially modified array. If isExpired can throw on a malformed expiresAt such as null, validate first or catch per record, because one throw aborts the pass and leaves the array half-cleaned.
3. Diagnose the intermittent `while` loop hang and describe guards you would add.
Read the worked answer
An infinite while almost always means some path through the body does not change the condition. Common causes: the index is incremented only in the else branch, so a skipped element loops forever; the loop compares against a cached originalLength while splicing; or it waits for sessions.length to reach 0 but never removes the element that fails the check. A while (sessions.length) combined with splice(i, 1) where i has moved past length also loops, because that splice does nothing. Defensive fixes: use a for loop with an explicit bound and change the bound when mutating, guarantee progress by incrementing in every branch, prefer filter, and add an iteration guard that throws a named error after roughly 2 * originalLength passes. Because it only fails in staging, look for data-dependent paths - an unrecognised expiresAt hitting a continue before the increment is the usual culprit.