for vs forEach: the differences that actually matter
for and forEach look interchangeable until they aren't. This post walks through what they really differ on — control flow, async behavior, performance, and the cases where each one is the right tool.
They look the same. They aren't.
Pick almost any JavaScript codebase and you'll find both of these next to each other, sometimes in the same file:
for (let i = 0; i < items.length; i++) {
doSomething(items[i]);
}
items.forEach((item) => {
doSomething(item);
});
The output is identical. New developers reasonably conclude they're stylistic alternatives — pick whichever reads nicer. That's true 80% of the time. The other 20% is where teams trip over subtle bugs that compound over months: a break that doesn't break, an await that doesn't wait, an iteration that silently skips elements that were added during the loop.
This post is about that 20%. The full picture of what for and forEach actually differ on, with the underrated cases that should drive your choice.
Control flow: break, continue, return
This is the biggest practical difference.
for (and for...of, for...in, while) supports the usual three escape hatches:
for (const item of items) {
if (item.skip) continue;
if (item.stop) break;
process(item);
}
forEach doesn't:
items.forEach((item) => {
if (item.skip) return; // works — exits THIS callback, like continue
if (item.stop) break; // SyntaxError — break/continue aren't legal here
});
A return inside a forEach callback works as a continue, because each callback is a separate function — returning from it just ends that one iteration. But there's no way to short-circuit the whole loop. If you discover halfway through that you've found what you're looking for, forEach is going to keep calling your callback for every remaining element regardless.
The standard workaround is to switch methods:
some()— stops at the firsttrue, useful as a "found it, stop" pattern.every()— stops at the firstfalse, useful as a "fail fast" validator.find()/findIndex()— stops at the first match and returns the value/index.for...of— when you genuinely needbreak/continue.
If your forEach body has an early-exit condition, you're almost always reaching for the wrong tool.
async/await: forEach silently swallows it
This is the bug I see catch the most experienced developers. Look at these two:
// Version A
for (const url of urls) {
await fetch(url);
}
// Version B
urls.forEach(async (url) => {
await fetch(url);
});
Version A processes URLs one at a time, in order. The outer function pauses on each await. Total time: sum of the request durations.
Version B does not wait. forEach calls your callback and discards the returned promise. Every fetch starts immediately, all in parallel, and the surrounding code keeps going while they're still in flight. If the code after the loop assumes the work is done, it won't be.
async function process(urls) {
urls.forEach(async (url) => {
const data = await fetch(url);
save(data);
});
console.log("done"); // prints BEFORE any save() finishes
}
forEach was specified before Promise existed in the language. It has no idea what a returned promise is. Its signature is "call this function for each element, ignore the return value, move on."
If you want sequential async iteration, use for...of. If you want concurrent async with a wait at the end, use Promise.all(urls.map(async (url) => ...)). forEach is the wrong tool for both.
Mutation during iteration
Both forms run into trouble when the array changes mid-loop, but they fail differently.
const xs = [1, 2, 3, 4, 5];
for (let i = 0; i < xs.length; i++) {
if (xs[i] === 2) xs.splice(i, 1);
}
// xs becomes [1, 3, 4, 5] — but element 3 was skipped (it slid into index 1, which we'd already passed)
const xs = [1, 2, 3];
xs.forEach((v) => xs.push(v * 10));
// stops after the original three — forEach captures the length at the start
forEach snapshots the length at the start of iteration. New elements pushed during the loop are ignored. for reads array.length every iteration, so it picks up new elements but also visits indices that have shifted under it. Neither is "correct" — they're just different kinds of wrong if your code is mutating the array it's iterating.
The honest rule: don't mutate the array you're iterating. Use filter, map, or build a new array. Either approach you take with mutation is asking for a bug a year from now when someone changes the predicate.
Performance: it almost never matters, but here's the truth
Benchmarks consistently show for is faster than forEach — sometimes meaningfully, on large arrays in hot loops, in older V8 versions. The reasons:
forEachcalls a function per element. Each call has stack-frame overhead.forEachreadsthisand the callback through the array prototype chain.- Modern JIT compilers optimize plain
forloops aggressively (loop unrolling, bounds-check elimination). They've gotten much better at optimizingforEach, but plainforis still the easier target.
In real code: ignore this. The difference is measurable on synthetic benchmarks (millions of iterations, empty bodies) and invisible in any loop that actually does work. If your loop body makes a network call, hits the DOM, parses JSON, or even just allocates an object, the iteration overhead is rounding error.
The cases where it matters: tight numeric loops over typed arrays (image processing, audio DSP, physics simulations). In those, you'll also want to skip forEach because typed arrays' forEach does more bookkeeping than a for loop. Everywhere else, write the version that reads better.
Sparse arrays
A historical quirk: forEach skips holes in sparse arrays.
const xs = [1, , 3]; // hole at index 1
xs.forEach((v, i) => console.log(i, v));
// 0 1
// 2 3 ← index 1 not visited
for (let i = 0; i < xs.length; i++) console.log(i, xs[i]);
// 0 1
// 1 undefined
// 2 3
In practice you almost never have a sparse array on purpose. But if you're working with the result of new Array(10) or an array where someone deleted an index, the behavior diverges.
When to reach for which
A simple decision tree:
- Need
break/continue? →for...of. - Iterating with an index you actually use? → classic
for. - Async work that should run sequentially? →
for...ofwithawait. - Async work that can run in parallel? →
Promise.all(arr.map(async ...)). - Just iterating to do a side effect, no early exit, no async? →
forEachis fine and reads well. - Building a new array, sum, group, etc? →
map/filter/reduce. Not a loop at all.
The argument I'd push back on is "always use for" or "always use forEach." Both exist for a reason, and the right one depends on what the loop body is doing. The mistakes happen when developers default to one without thinking about the four edge cases above — break/continue, async, mutation, sparse arrays. If your loop hits any of them, the choice isn't stylistic anymore.
A pattern that consolidates this
When code review keeps catching the same "you used forEach with async" mistake, the fastest fix isn't more code review — it's a lint rule. ESLint's no-await-in-loop flags sequential awaits (which is sometimes what you want, but worth a comment), and unicorn/no-array-for-each aggressively pushes toward for...of. Pick the policy your team can live with, document the exceptions, and let the linter carry the load.
The underlying point: for and forEach are not interchangeable. They overlap in the easy cases and diverge in the cases that produce subtle bugs. Knowing which is which is what separates "writes code that works" from "writes code that works in every case the next developer will throw at it."
Rate this post
All fields are optional. Just stars is fine.