JavaScript forEach Loop: A Visual Guide
An array contains three order totals, and the same callback needs to run once for each one. forEach() handles that repetition, but its rules about missing elements, mutations, return values and promises decide which callbacks actually finish when you expect.
JavaScript
forEach()is an array method that invokes a callback for each existing indexed element in ascending order, passes the value, index and array to that callback, and returnsundefined.
What Is a JavaScript forEach Loop?
JavaScript has no foreach keyword. Array.prototype.forEach() is a method, which means you call it on an array with dot notation:
const quantities = [2, 4, 1];
quantities.forEach((quantity) => {
console.log(quantity);
});
2
4
1
The function passed to forEach() is a callback. forEach() invokes that function for the element at index 0, then the element at index 1, and continues through the starting index range in ascending order.
Developers commonly call this a “forEach loop” because it repeats an operation. The distinction still matters. A for or for...of construct is a loop statement whose body supports loop controls such as break and continue. A forEach() call invokes a separate function for each visited element.
That makes forEach() a natural fit when each element should cause a side effect. Logging a value, adding it to an external total, updating an existing object and changing DOM elements all fit that description.
It is not the method for every kind of repetition. If the task is to calculate and collect one new value for every old value, map() states that intention directly. Arrays covers the wider collection of array methods, while Loops: while and for explains the loop statements themselves.
The method is also generic. Its algorithm works with an object that has a length and indexed properties, although arrays are the ordinary case and the clearest place to learn its rules.
forEach Syntax and Callback Arguments
The basic call has one required argument, the callback:
array.forEach(callback);
A second form supplies the value that a traditional function receives as this:
array.forEach(callback, thisArg);
The callback itself can receive three arguments:
valueis the value at the current index.indexis the current numeric index.arrayis the object being traversed.
Most callbacks need only the value:
const filenames = ['notes.md', 'index.js', 'styles.css'];
filenames.forEach((filename) => {
console.log(filename);
});
notes.md
index.js
styles.css
Add the index when the element’s position matters:
const filenames = ['notes.md', 'index.js', 'styles.css'];
filenames.forEach((filename, index) => {
console.log(`${index}: ${filename}`);
});
0: notes.md
1: index.js
2: styles.css
The third argument is the same array reference on every callback invocation. That lets a callback inspect the collection around its current element:
const temperatures = [18, 21, 19];
temperatures.forEach((temperature, index, array) => {
console.log(index, temperature, array === temperatures, array.length);
});
0 18 true 3
1 21 true 3
2 19 true 3
All three calls receive temperatures as their array argument. The reference is not a frozen snapshot, so mutations made through it affect the original array.
A normal function can receive a thisArg. Say a report callback needs a prefix stored on another object:
const reporter = {
prefix: 'score',
};
[8, 10].forEach(function (score, index) {
console.log(`${this.prefix} ${index}: ${score}`);
}, reporter);
score 0: 8
score 1: 10
reporter becomes this during each callback call. Without a supplied thisArg, the specification passes undefined; what a non-strict traditional function exposes can then depend on ordinary function this rules.
Arrow functions behave differently because they keep their lexical this. Passing a thisArg does not replace it:
const reporter = { prefix: 'score' };
[8].forEach((score) => {
console.log(reporter.prefix, score);
}, { prefix: 'ignored' });
score 8
When an arrow callback needs the surrounding object, referring to that object by name is clearer. When a normal callback is deliberately written around this, pass thisArg as the second argument.
How forEach Runs, Step by Step
Start with three shipment counts and an external total. The callback logs all three arguments, changes the total and returns a number:
const shipments = [3, 5, 2];
let total = 0;
const result = shipments.forEach((value, index, array) => {
total += value;
console.log(index, value, array === shipments, total);
return value * 10;
});
console.log(result, total);
0 3 true 3
1 5 true 8
2 2 true 10
undefined 10
The complete callback trace is:
| Visit | value | index | array === shipments | Side effect on total | Callback result |
|---|---|---|---|---|---|
| first | 3 | 0 | true | 0 becomes 3 | 30, discarded |
| second | 5 | 1 | true | 3 becomes 8 | 50, discarded |
| third | 2 | 2 | true | 8 becomes 10 | 20, discarded |
forEach() does not collect or otherwise use 30, 50 or 20. After the final callback, the method returns undefined.
That is the central difference from map(). A map() callback’s result becomes an element in a new array, while a forEach() callback’s result goes nowhere:
const prices = [4, 7, 2];
const forEachResult = prices.forEach((price) => price * 2);
const mapResult = prices.map((price) => price * 2);
console.log(forEachResult);
console.log(mapResult.join(', '));
undefined
8, 14, 4
Missing indices add another rule. An empty slot is not the same thing as an element containing undefined:
const statuses = [];
statuses[0] = undefined;
statuses[2] = 'ready';
statuses.forEach((status, index) => {
console.log(index, String(status));
});
console.log(0 in statuses, 1 in statuses, 2 in statuses);
0 undefined
2 ready
true false true
Index 0 exists, so its undefined value reaches the callback. Index 1 is missing, so forEach() skips it. Index 2 exists and runs normally.
An empty slot has no element at that index. An explicit undefined is an element whose value happens to be undefined.
This check for an existing index is part of the method’s defined behavior, not a truthiness test. Values such as undefined, null, 0, false and an empty string still trigger the callback when their indices exist. The ECMAScript forEach() algorithm states the steps precisely.
Practical forEach Examples
The simplest use is logging each value while inspecting a program:
const activeFiles = ['app.js', 'router.js', 'store.js'];
activeFiles.forEach((file) => {
console.log(`loading ${file}`);
});
loading app.js
loading router.js
loading store.js
Logging is a side effect. The array remains unchanged, and no replacement array is needed.
A total also works, but the accumulator lives outside the callback because forEach() does not carry a callback result forward:
const invoiceLines = [1250, 799, 450];
let totalCents = 0;
invoiceLines.forEach((lineCents) => {
totalCents += lineCents;
});
console.log(totalCents);
2499
The code runs in index order and adds each value to totalCents. For a calculation whose whole purpose is producing one accumulated value, another array method may communicate the result more directly, but this example exposes the mechanics without hiding the changing total.
Callbacks can also update objects already stored in an array:
const tasks = [
{ title: 'write tests', done: false },
{ title: 'update docs', done: false },
];
tasks.forEach((task) => {
task.done = true;
});
console.log(tasks[0].done, tasks[1].done);
true true
forEach() did not mutate the array by itself. The callback changed the done property of each object, and those are the same objects held by tasks.
That distinction matters. The method controls which callbacks run; the callback controls what those calls do.
Browser code often uses forEach() for DOM side effects. This example adds a class and an accessible label to each matching button:
const buttons = document.querySelectorAll('.save-button');
buttons.forEach((button, index) => {
button.classList.add('is-ready');
button.setAttribute('aria-label', `Save document ${index + 1}`);
});
There is no output block because the snippet changes DOM elements rather than printing text. The visible result belongs on the page. Styles and classes covers the browser APIs used for that kind of update.
These examples share one rule: each existing item causes an observable action. That rule keeps forEach() out of code that is really trying to construct a transformed or filtered array, which also makes the intention easier to see during a coding style review.
The Rules That Cause forEach Bugs
A forEach() callback is a function body, so break and continue do not control the surrounding method call. A bare return ends only the current callback invocation:
const scores = [7, 0, 9];
scores.forEach((score) => {
if (score === 0) return;
console.log(score);
});
console.log('finished');
7
9
finished
That return resembles a continue in this small example because it skips the remaining statements for the current value. It cannot stop the later callback for 9.
An exception does stop forEach() through abrupt completion. Throwing an exception merely to imitate break is normally the wrong design, though. Use some(), find(), for...of or a regular loop when stopping early is part of the task.
Mutation has two timelines. forEach() reads the traversal length before the first callback, but it reads an existing element’s value when that index is visited.
Here is one mutation sequence containing a changed value, a deletion and an append:
const stages = ['draft', 'review', 'publish'];
stages.forEach((stage, index, array) => {
if (index === 0) {
array[1] = 'edit';
array.push('archive');
}
if (index === 1) {
delete array[2];
}
console.log(index, stage, array.length);
});
console.log(2 in stages, stages[3]);
0 draft 4
1 edit 4
false archive
The mutation timeline is:
| Moment | Starting range | Change | What forEach() does |
|---|---|---|---|
| before callbacks | indices 0 through 2 | starting length is 3 | only this range can be visited |
visit index 0 | unchanged | index 1 becomes 'edit'; index 3 is appended | later index 1 reads 'edit'; index 3 stays outside the range |
visit index 1 | unchanged | index 2 is deleted | the callback has already received 'edit' |
check index 2 | unchanged | index 2 is missing | the callback is skipped |
| finish | unchanged | index 3 still contains 'archive' | the appended element is not visited |
The starting length is captured. The starting values are not.
This rule explains why editing an unvisited value affects its later callback, deleting an unvisited element removes that callback, and appending beyond the original range adds data without adding another visit. Mutating an array during traversal can be valid, but the code now depends on timing that a reader must reconstruct.
Two other failures are more direct. Passing something that is not callable as the callback throws a TypeError, and an error thrown by a real callback stops the method before later elements are visited.
Why async and await Do Not Work as Expected
An async function returns a promise. forEach() invokes the callback but discards its return value, so it neither collects nor waits for that promise.
The event order shows the gap:
console.log('before');
['draft', 'review'].forEach(async (stage) => {
console.log(`start ${stage}`);
await Promise.resolve();
console.log(`finish ${stage}`);
});
console.log('after');
before
start draft
start review
after
finish draft
finish review
Both callbacks start during the forEach() call. Each reaches await and returns a pending promise, but forEach() moves to the next index without using it. The outer code prints after before either callback resumes.
The await works inside each callback. What it does not do is delay forEach() or the statement after it. Event loop: microtasks and macrotasks explains when those promise continuations get their turn.
For sequential work, use for...of and await each operation before advancing:
async function saveInOrder(documents) {
for (const document of documents) {
await saveDocument(document);
}
}
The second save starts only after the first awaited call settles. This is the right shape when ordering matters or when one operation depends on the previous one.
When the operations may begin concurrently, create one promise per element with map() and await the combined promise:
async function saveTogether(documents) {
const promises = documents.map((document) => saveDocument(document));
const savedDocuments = await Promise.all(promises);
return savedDocuments;
}
map() collects the promises, and Promise.all() produces a promise for their combined completion. This is not a replacement for strict sequential work because the calls to saveDocument() begin as map() visits their elements.
The contrast is small but decisive:
forEach(async callback)starts callbacks and ignores their returned promises.for...ofwithawaithandles one operation before starting the next iteration.Promise.all()withmap()keeps every returned promise and waits for the group.
Async failures make the choice more than a matter of printed order. The surrounding function can await the for...of sequence or the promise returned by Promise.all(). It cannot await a promise that forEach() never returns.
When to Use forEach and When Not To
Choose the construct from the result the code needs, not from which syntax is shortest.
| Task | Use | Reason |
|---|---|---|
| perform one side effect for each existing element | forEach() | the callback result is not needed |
| create one transformed value per element | map() | callback results form a new array |
| keep elements that pass a test | filter() | matching values form a new array |
| ask whether any element passes | some() | it can finish after a match |
| retrieve the first matching element | find() | it returns that element and can finish early |
use break, continue or sequential await | for...of | the work stays inside a loop statement |
| start async work concurrently and await all results | Promise.all() with map() | the promises are collected and awaited |
| control indices, bounds or update expressions directly | for | the loop statement exposes those controls |
Use forEach() when the sentence describing the work sounds like “for each existing item, perform this action.” Use map() when it sounds like “turn every item into another value,” and use filter(), some() or find() when a test decides what result is needed.
Reach for for...of when control flow belongs inside the repetition. Reach for Promise.all() with map() when asynchronous operations can overlap and the calling code still needs one promise to await.
That choice rule scales beyond one example. JavaScript Projects for Practice: 18 Ideas With Specs gives it larger collections to work against, and JavaScript Fundamentals puts arrays, functions and control flow into one continuous path.