Last week I was reviewing a production bug caused by a subtle array iteration mistake. The team had used a loop that returned early in one branch, but the data pipeline needed every item processed, even when a particular element failed validation. That’s the moment I’m reminded why forEach() still matters. It’s not flashy, but it’s dependable when you want to do something with every element and you don’t want the loop to return anything. If you’ve ever needed to log, validate, hydrate UI state, or build up a new structure while keeping the original array unchanged, forEach() is a solid first pick.
I’ll walk you through how forEach() works, why its callback signature is easy to misuse, and how it behaves with holes, mutations, and async tasks. I’ll also show you when I avoid it in favor of map, reduce, or a simple for...of. If you want practical iteration patterns that work cleanly in modern JavaScript, you’re in the right place.
What forEach() Really Does (and Doesn’t)
forEach() is an Array method that calls your callback once for each element that exists in the array. It does not return a new array, and it does not create a copy. You get the current element, its index, and the array itself. That’s it. In my day‑to‑day work, I think of forEach() as a side‑effect loop. It’s for actions: logging, pushing into another array, mutating objects, updating UI state, or writing to a metrics sink.
Here’s the core syntax you’ll see everywhere:
array.forEach(callback(element, index, array), thisValue);
Two practical details matter more than any formal definition:
1) forEach() always returns undefined.
2) You cannot break or continue it.
That makes its intent loud and clear. If you’re trying to compute and return a new array, I’d rather you use map. If you’re trying to filter elements, use filter. If you need early exit, I strongly prefer for...of or a classic for loop. forEach() shines when the work itself is the goal, not the return value.
Callback Parameters and thisArg in Real Code
The callback signature is small, but people still trip over it. I’ll show you the shape I always keep in my head:
array.forEach((element, index, array) => {
// do something
}, thisArg);
element: current elementindex(optional): current index, 0‑basedarray(optional): the array being iteratedthisArg(optional): value ofthisinside the callback
I rarely use thisArg in modern code because arrow functions close over this from their lexical scope. But you still see it in older codebases or when using a regular function callback. If you stick to arrow functions, thisArg will have no effect.
Here’s a quick example where thisArg is relevant, and one where it is ignored.
const logger = {
prefix: "[audit]",
};
const actions = ["create", "update", "delete"];
// thisArg works with function callbacks
actions.forEach(function (action, i) {
console.log(this.prefix, i, action);
}, logger);
Now compare it to an arrow callback:
const logger = {
prefix: "[audit]",
};
const actions = ["create", "update", "delete"];
// thisArg is ignored with arrow callbacks
actions.forEach((action, i) => {
console.log(this?.prefix, i, action); // this is from outer scope
}, logger);
If you need thisArg, you must use a regular function callback. Otherwise, keep it simple and use arrow functions.
Straightforward Iteration: The Pattern I Use Daily
You can use forEach() to do the everyday iteration work that keeps code readable. Here’s a clean example that prints each item:
const queueIds = [101, 102, 103, 104];
queueIds.forEach((id) => {
console.log("queued", id);
});
You should keep callbacks short and obvious. If you need more than a few lines, pull the body into a named function. I prefer naming it after the side effect it creates:
function recordMetric(id, index) {
console.log("metric", { id, index, ts: Date.now() });
}
const queueIds = [101, 102, 103, 104];
queueIds.forEach(recordMetric);
That’s the best “small tools” path: clear intent, no ambiguity, and no return value that could confuse the reader.
Building a New Array the Safe Way
Even though forEach() doesn’t return a new array, you can still build one yourself. It’s a common pattern in legacy code, and it can be perfectly fine if you want to do more than just transform values. Here’s the classic copy:
const source = [12, 24, 36];
const copy = [];
source.forEach((value) => {
copy.push(value);
});
console.log(copy);
If you only need a transformed array, you should use map, which is cleaner and easier to reason about. But if you’re doing more work (like validation, conditional pushes, or logging), forEach() can keep everything together.
Here’s an example where I use forEach() to build a new array while keeping extra metadata.
const events = [
{ id: "e1", status: "ok" },
{ id: "e2", status: "failed" },
{ id: "e3", status: "ok" },
];
const okIds = [];
const failures = [];
events.forEach((evt) => {
if (evt.status === "ok") {
okIds.push(evt.id);
} else {
failures.push({ id: evt.id, reason: "status" });
}
});
console.log(okIds);
console.log(failures);
Yes, you could do this with reduce, but forEach() is more direct and easier to scan. In high‑pressure reviews, readability beats cleverness.
Calculations and Side Effects: A Balanced Example
forEach() is great when you want a computed result and a side effect like logging or metrics. I use it frequently for this kind of task.
const inputs = [1, 29, 47];
const squares = [];
inputs.forEach((n) => {
const value = n * n;
squares.push(value);
console.log("square", n, "->", value);
});
console.log(squares);
For a pure transform, map is cleaner. But when you need both a transformed array and extra steps for each element, forEach() keeps the flow obvious. You can do the same with a for...of, and sometimes I prefer that for early exit or async work. But for synchronous operations where each item is processed, forEach() is perfectly aligned with intent.
Mutation, Holes, and What “Each Element” Means
One of the biggest misunderstandings I see in code reviews is assuming forEach() touches every index, even empty slots. It doesn’t. It only iterates over existing elements. That means it skips holes in sparse arrays.
const sparse = ["a", , "c"]; // note the hole at index 1
sparse.forEach((value, index) => {
console.log(index, value);
});
// logs:
// 0 "a"
// 2 "c"
If you need to iterate over all indices, even holes, use a classic for loop with length, or fill the array first.
Mutations during iteration also matter. forEach() reads the length at the start, then calls your callback for each element that exists at the time it reaches that index. If you push items during a forEach(), they won’t be visited in the same pass. If you delete items, the callback simply won’t run for them.
const data = ["a", "b", "c"];
data.forEach((value, index, arr) => {
if (index === 0) arr.push("d");
console.log(value);
});
// logs: a, b, c
// "d" is not visited
If you need dynamic growth during iteration, that’s a sign you should switch to a for loop or a while loop. forEach() is deliberately predictable, and that predictability is a strength.
When I Avoid forEach()
I use forEach() often, but I avoid it in a few specific cases. You should too.
1) Early Exit Needed
If you need to stop once a condition is met, forEach() is the wrong tool. You can’t break or continue. I’ll switch to for...of or a classic for loop.
const ids = [1, 2, 3, 4, 5];
let found = null;
for (const id of ids) {
if (id === 3) {
found = id;
break;
}
}
console.log(found);
2) You Need a Return Value
If you want a new array, use map. If you want to filter, use filter. If you want a single accumulated result, use reduce. forEach() doesn’t signal intent, and it returns undefined.
3) Async Work
This is a big one. forEach() does not await asynchronous callbacks. If you use an async callback, the loop won’t wait for promises to resolve.
const urls = ["/api/a", "/api/b", "/api/c"];
urls.forEach(async (url) => {
const res = await fetch(url);
console.log(await res.json());
});
console.log("done?"); // prints before requests finish
If you need sequential async processing, use for...of with await inside. If you want parallel behavior, use Promise.all with map.
// Sequential
for (const url of urls) {
const res = await fetch(url);
console.log(await res.json());
}
// Parallel
const responses = await Promise.all(urls.map((url) => fetch(url)));
When I see async inside a forEach() callback, I usually request a change immediately.
Common Mistakes I See in Code Reviews
I review a lot of JavaScript, and these are the mistakes that keep showing up. If you avoid them, your code will be clearer and easier to maintain.
Mistake 1: Returning from forEach() to Build Arrays
Developers sometimes try this:
const values = [1, 2, 3];
const doubled = values.forEach((n) => n * 2);
console.log(doubled); // undefined
That fails because forEach() returns undefined. Use map instead.
const values = [1, 2, 3];
const doubled = values.map((n) => n * 2);
console.log(doubled); // [2, 4, 6]
Mistake 2: Expecting continue
Some developers simulate continue by returning early from the callback. That works inside the callback, but it does not skip the rest of the array. It only skips the remaining statements in that callback call.
const ids = [1, 2, 3, 4];
ids.forEach((id) => {
if (id === 2) return; // only skips this callback body
console.log(id);
});
That’s fine if you understand it, but don’t confuse it with loop control.
Mistake 3: Mutating Arrays in Complex Ways
Mutating the array you’re iterating over can be risky. If you absolutely need to do it, know how forEach() behaves. A safer approach is to iterate over a shallow copy:
const items = ["a", "b", "c", "d"];
[...items].forEach((value) => {
if (value === "b") {
items.splice(items.indexOf(value), 1);
}
});
console.log(items); // ["a", "c", "d"]
This keeps the iteration stable and avoids surprising skips.
Performance and Readability: My Actual Guidance
Performance is rarely the deciding factor for forEach() in front‑end or Node applications. The difference between forEach() and for...of is usually tiny compared to API calls, rendering, or file IO. If I care about micro‑performance, I’ll benchmark in the specific runtime, but for most real projects I choose readability and intent.
That said, here’s how I pick tools in practice:
- Use
forEach()when you want side effects and no return value. - Use
mapwhen you need a transformed array. - Use
filterwhen you need a subset. - Use
reducewhen you need a single accumulated result. - Use
for...ofwhen you needbreak,continue, orawait.
If you keep this mental map, your code reviews will go faster and your loops will be more obvious to everyone else reading your code.
Real‑World Scenarios Where forEach() Fits Perfectly
Let me show you a few scenarios I run into regularly, with examples that you can drop into real codebases.
UI State Updates in Front‑End Apps
In a UI state store, I often need to mark multiple entities as “dirty” after a batch update. forEach() is great for that.
const entities = [
{ id: "u1", dirty: false },
{ id: "u2", dirty: false },
{ id: "u3", dirty: false },
];
entities.forEach((entity) => {
entity.dirty = true; // intentional mutation
});
console.log(entities);
Server‑Side Logging and Metrics
In Node services, I frequently log or measure across a batch request.
const jobs = [
{ id: "j1", ms: 12 },
{ id: "j2", ms: 18 },
{ id: "j3", ms: 9 },
];
jobs.forEach((job) => {
console.log("job", job.id, "took", job.ms, "ms");
});
Validation Without Returning Anything
forEach() works well for validation that builds a separate list of issues.
const records = [
{ id: "a", email: "[email protected]" },
{ id: "b", email: "invalid" },
];
const issues = [];
records.forEach((record) => {
if (!record.email.includes("@")) {
issues.push({ id: record.id, issue: "email" });
}
});
console.log(issues);
These are all side‑effect patterns. Each is clear, predictable, and a great fit for forEach().
Choosing Between forEach() and map() with a Table
Many devs confuse forEach() and map(). Here’s the simplest way I explain it:
Best Choice
—
forEach()
map()
for...of
await works as expected for...of or for
break forEach()
This keeps everyone aligned and reduces debate in code review.
Edge Cases You Should Know (and Why They Matter)
Let’s walk through a few edge cases that often surprise people in production.
1) Empty Arrays
forEach() on an empty array does nothing. This is usually fine and removes the need for guard clauses.
const empty = [];
empty.forEach(() => {
// never runs
});
2) thisArg with Strict Mode
In strict mode, this in a function callback with no thisArg will be undefined. If you need a context, pass it explicitly.
3) Changing Values During Iteration
If you reassign items as you go, forEach() uses the updated values when it reaches those indices. That can be useful or dangerous depending on intent. I usually avoid complex mutation patterns to keep behavior easy to reason about.
4) Large Arrays
On very large arrays, forEach() is fine, but if you need to stop early or keep memory tight, a for loop can be more direct. In practice, when arrays reach hundreds of thousands of entries, I measure and choose the simplest loop that matches intent.
Modern Workflow Tips from 2026
Even for a basic method like forEach(), how you work has changed. I rely on two habits that reduce mistakes:
1) Static analysis: I keep ESLint rules that flag async inside forEach().
2) AI‑assisted review: I ask my code review assistant to scan for forEach() patterns that should be map or for...of.
These aren’t gimmicks. They catch real bugs, especially in teams where JavaScript experience varies widely. A well‑placed lint rule prevents a class of async bugs that are expensive to debug.
Deep Dive: Control Flow Semantics
It helps to understand the internal guarantees forEach() gives you:
- The callback runs in ascending index order, starting at 0.
- The length is captured up front; later
push/unshiftitems are skipped in that pass. - Deleting an element before its turn means the callback won’t run for that index.
- Reassigning a value before its index is visited is reflected in the callback.
Knowing these rules lets you predict outcomes when the array is being touched by other code (like a reducer updating state mid‑loop) and avoid spooky action‑at‑a‑distance.
Iterating Sparse Data Safely
Sparse arrays show up in telemetry buffers and CSV imports. If you actually need to visit holes, you can normalize the data first:
const raw = ["a", , "c"];
const normalized = Array.from({ length: raw.length }, (_, i) => raw[i] ?? null);
normalized.forEach((value, i) => {
console.log(i, value);
});
Alternatively, use Object.keys(raw) with forEach() to only hit defined indices explicitly:
Object.keys(raw).forEach((key) => {
console.log(key, raw[key]);
});
That’s clear to reviewers: you’re iterating keys, not assuming contiguous data.
Error Handling Patterns with forEach()
forEach() itself doesn’t catch exceptions; errors bubble up like any synchronous code. I use two patterns:
Pattern 1: Collect errors without throwing
const errors = [];
items.forEach((item) => {
try {
validate(item);
} catch (err) {
errors.push({ id: item.id, message: err.message });
}
});
Pattern 2: Fail fast
If you want a hard stop on first error, use for...of with throw, because forEach() can’t break. Reserve forEach() for the collect‑and‑continue case.
Async: Sequential vs Parallel Done Right
forEach() doesn’t await, but you can still structure async work cleanly.
Sequential with for...of (recommended when order matters):
for (const userId of userIds) {
const profile = await fetchProfile(userId);
await cacheProfile(profile);
}
Parallel with map + Promise.all (recommended when order doesn’t matter):
await Promise.all(
userIds.map(async (userId) => {
const profile = await fetchProfile(userId);
await cacheProfile(profile);
})
);
I keep forEach() for synchronous flows. If I see async inside forEach(), I treat it as a code smell to refactor immediately.
Testing and Debugging Loops
When a bug hides in iteration logic, I use a few simple moves:
- Log indices explicitly:
console.debug({ index, value })inside the callback. - Snapshot lengths before and after: store
const startLen = arr.length;and assert it hasn’t changed when mutation is unexpected. - Use spies in tests: In Jest or Vitest, spy on the callback to ensure it was called the expected number of times and in order.
Example in a test:
test("processes every item", () => {
const spy = vi.fn();
["a", "b", "c"].forEach(spy);
expect(spy).toHaveBeenCalledTimes(3);
expect(spy.mock.calls[0][0]).toBe("a");
});
TypeScript Considerations
TypeScript’s inference for forEach() is solid, but two tips make life easier:
1) Narrow types inside the callback: Type guards work as usual.
2) Readonly arrays: readonly string[] still exposes forEach() because it doesn’t mutate structure. You can safely iterate without casting.
const records: readonly (User | Admin)[] = fetchAll();
records.forEach((r) => {
if (isAdmin(r)) r.auditCount += 1; // allowed because the object is mutable even if array is readonly
});
If you want to enforce immutability on elements, make the element type deeply readonly, not just the array.
Functional vs Imperative Framing
A lot of debate comes from mixing paradigms. I think of forEach() as the imperative sibling of map. When I need a pure function with no external effects, I pick map. When I explicitly want a side effect, I pick forEach(). Keeping that mental boundary prevents accidental side effects creeping into “pure” sections of code.
Interop with Set, Map, and Array‑Like Data
Set and Map also expose forEach(), but the callback signatures differ. For Map, the callback receives (value, key, map); for Set, it receives (value, value, set) (the first two params are the same for historical reasons). If you switch between Array and Map/Set, name your callback parameters clearly to avoid mix‑ups.
const m = new Map([["a", 1], ["b", 2]]);
m.forEach((value, key) => {
console.log(key, value);
});
When iterating over DOM NodeList or HTMLCollection, modern browsers let you spread into an array and then use forEach() consistently:
[...document.querySelectorAll("button")].forEach((btn) => btn.disabled = true);
Batching and Chunking with forEach()
If you process large inputs, you may want to batch work to avoid blocking the event loop. You can combine forEach() with chunking utilities:
const CHUNK = 1000;
const chunks = [];
for (let i = 0; i < bigArray.length; i += CHUNK) {
chunks.push(bigArray.slice(i, i + CHUNK));
}
chunks.forEach((chunk, idx) => {
queueMicrotask(() => {
chunk.forEach(processItem);
console.log("processed chunk", idx);
});
});
Here the outer forEach() schedules chunk processing without overwhelming the main thread.
Linting Rules That Strengthen forEach() Usage
Two rules I turn on in ESLint configs to keep intent clear:
no-await-in-loop: avoids accidentalawaitinsideforEach()by steering developers tofor...of.array-callback-return: reminds developers thatmap/filter/reduceshould return values, butforEach()callbacks should not. This reduces accidental misuse offorEach()for transformations.
With these in place, code reviews focus on business logic instead of loop semantics.
Debugging Sparse Iteration Bugs
If a callback isn’t firing where you expect, check for holes or delete usage. Tools like console.table(array) show empty slots as . I often convert to entries before iterating:
Array.from(array.entries()).forEach(([index, value]) => {
console.log(index, value);
});
Now you can see exactly which indices exist.
Micro‑Benchmarks: When They Matter
Every few years I measure loop performance out of curiosity. In 2026 V8/SpiderMonkey/JavaScriptCore, differences among for, for...of, and forEach() are typically within a few percent for arrays under ~100k items. Past that, raw for edges ahead. But in production apps, rendering or network dominates. I only reach for a for loop purely for speed when I’ve proven the loop is a measurable hotspot.
Defensive Coding: Avoiding Hidden Side Effects
Because forEach() is about side effects, be intentional:
- Keep callbacks small; extract helpers when they exceed ~5–7 lines.
- Avoid writing to global state inside the callback; pass dependencies in explicitly.
- Prefer immutable inputs when mutation isn’t required. Clone objects before mutating if the caller shouldn’t see changes.
orders.forEach((order) => {
const safe = { ...order };
safe.total = computeTotal(safe);
cacheTotals.push(safe.total);
});
Combining forEach() with reduce for Clarity
Sometimes I pair them: reduce to aggregate, forEach() to handle side effects like logging.
const sum = numbers.reduce((acc, n) => acc + n, 0);
numbers.forEach((n) => console.log("saw", n));
console.log("total", sum);
Separation of concerns is clear: reduce does math; forEach() handles effects.
Refactoring Checklist: Should This Be forEach()?
When I review loops, I ask:
- Is the goal a side effect with no returned structure? →
forEach()is fine. - Do we need early exit? → use
for...of. - Are we mapping to a new array? → use
map. - Are we filtering? → use
filter. - Are we combining values? → use
reduce. - Is there async
await? → usefor...oforPromise.all.
If a loop fails this checklist, I refactor immediately.
Production Stories: Lessons Learned
1) Async validation bug: A teammate used forEach() with async to validate user imports. Errors were never awaited, and the API responded “success” before validation finished. Fix: switch to for...of with await and collect errors.
2) Sparse data gap: An analytics buffer had holes because of array[index] = value writes. forEach() skipped missing indices, so some events never flushed. Fix: replace holes with null and iterate entries explicitly.
3) Mutation surprise: A batch updater pushed new items during forEach() expecting them to process in the same pass. They didn’t. Fix: split creation and processing into two phases.
Each of these was solved by understanding the deterministic rules of forEach().
Tooling Tip: Instrumenting forEach() Calls
In large codebases, you might want observability around heavy loops. A small wrapper helps:
function forEachWithTiming(array, cb) {
const start = performance.now();
array.forEach(cb);
const end = performance.now();
return end - start;
}
const ms = forEachWithTiming(bigList, processItem);
console.log(processed in ${ms.toFixed(2)}ms);
Now you have lightweight timing without changing every caller.
DOM and Framework Contexts
Framework code often hides iteration inside reactivity. A few examples:
- React: Avoid using
forEach()inside render to produce JSX; usemapso you actually return elements. ButforEach()is great inside effects (useEffect) for logging, subscriptions, or imperative DOM tweaks. - Vue/Svelte: Same idea—use template loops (
v-for,{#each}) for rendering,forEach()for imperative side effects inside lifecycle hooks. - Node streams: When consuming a fully buffered array of chunks,
forEach()is fine for synchronous transformations before streaming out again.
Being explicit about purpose (render vs side effect) keeps code intention sharp.
Migration Patterns: Moving From forEach() to Other Loops
If you inherit code that overuses forEach(), migrate carefully:
- For transformations, replace with
mapand remove manual pushes. - For filters, replace with
filterand delete conditional pushes. - For async sequential flows, replace with
for...ofandawait. - For parallel async, replace with
Promise.all+map.
Do it in small commits; tests make sure behavior stays intact.
Checklist Before Shipping Code with forEach()
- Does the callback stay under ~7 lines? If not, extract.
- Are you avoiding
asyncinside? If not, reconsider the loop type. - Is mutation intentional? If not, clone first.
- Are holes possible? If yes, normalize data or iterate entries.
- Do you truly not need a return value? If you do, swap to
map/filter/reduce.
Final Thoughts
forEach() is humble, but it’s a workhorse. It makes intent obvious when you want to act on every element and don’t need a returned value or control flow. The moment you need early exits, async sequencing, or a new structure, you have better tools. Keep these boundaries clear, and forEach() will stay a reliable, readable part of your toolkit.



