Using forEach to Work with Arrays in TypeScript
You've just deployed your code, and a user reports that only some items in their cart are being processed. After digging through logs, you realize your forEach loop with async API calls isn't waiting for each request to finish. The loop completes instantly while the network requests are still in flight. Sound familiar?
The forEach method is one of the most commonly used array methods in TypeScript, but it's also one of the most misunderstood—especially when it comes to async operations, error handling, and when to use it instead of alternatives like map or traditional for loops.
const numbers = [1, 2, 3, 4];
numbers.forEach(num => console.log(num)); // Logs each number in the array
When to Use forEach (And When Not To)
Before diving into syntax and examples, let's clarify when forEach is the right choice. This'll save you time and help you avoid common pitfalls.
| Scenario | Best Choice | Why |
|---|---|---|
| Performing side effects (logging, DOM updates, sending analytics) | forEach | Clear intent, built for side effects |
| Transforming data to a new array | map | Returns new array, chainable |
| Async operations (sequential) | for...of | Supports await properly |
| Async operations (parallel) | Promise.all() + map | Concurrent execution with error handling |
| Need to break early | for, for...of, or some()/every() | forEach can't be stopped |
| Performance-critical with huge datasets | Traditional for loop | Lowest overhead, fastest execution |
Use forEach when:
- You need to perform side effects on each element (updating UI, logging, sending events)
- You want clearer intent than a traditional
forloop - You're working with small to medium datasets where performance isn't critical
- You don't need to transform data or return values
Don't use forEach when:
- You need to
awaitasync operations sequentially - You want to transform an array (use
map) - You need to break out of the loop early
- You're processing massive datasets where performance matters
Syntax and Basic Usage of forEach
The forEach method takes a callback function that executes on each array element. This function can accept three parameters: the current element, index, and the entire array.
const fruits = ['apple', 'banana', 'cherry'];
// Basic usage with just the element
fruits.forEach(fruit => console.log(fruit));
// Using index and element
fruits.forEach((fruit, index) => console.log(`${index}: ${fruit}`));
// Accessing all three parameters
fruits.forEach((fruit, index, array) => {
console.log(`${index}: ${fruit} (${array.length} total fruits)`);
});
// Type-safe callback parameters
type Fruit = string;
const typedFruits: Fruit[] = ['apple', 'banana', 'cherry'];
typedFruits.forEach((fruit: Fruit) => console.log(fruit));
Understanding forEach vs Other Loop Methods
Quick Reference: Loop Comparison
interface Task {
id: number;
title: string;
completed: boolean;
}
const tasks: Task[] = [
{ id: 1, title: 'Review code', completed: false },
{ id: 2, title: 'Update docs', completed: true },
{ id: 3, title: 'Fix bug', completed: false }
];
// forEach - for side effects, always returns undefined
tasks.forEach(task => {
updateTaskUI(task); // DOM update
});
// Returns: undefined
// map - transforms data, returns new array
const titles = tasks.map(task => task.title);
// Returns: ['Review code', 'Update docs', 'Fix bug']
// filter - selects items, returns new array
const incomplete = tasks.filter(task => !task.completed);
// Returns: [{ id: 1, ... }, { id: 3, ... }]
// Traditional for - full control, best performance
for (let i = 0; i < tasks.length; i++) {
if (tasks[i].completed) break; // Can break early
console.log(tasks[i].title);
}
// for...of - clean syntax, supports await
for (const task of tasks) {
await processTask(task); // Properly waits
}
forEach vs for vs for...of
Each loop type has specific use cases. Here's when to reach for each one:
const tasks: Task[] = [
{ id: 1, title: 'Review code', completed: false },
{ id: 2, title: 'Update docs', completed: true }
];
// Traditional for loop: when you need to compare adjacent elements
for (let i = 0; i < tasks.length - 1; i++) {
if (tasks[i].completed && !tasks[i + 1].completed) {
notifyIncomplete(tasks[i + 1]);
}
}
// forEach: for side effects (logging, DOM updates, analytics)
tasks.forEach(task => {
updateTaskUI(task);
trackAnalytics('task_viewed', task.id);
});
// for...of: when you need 'await' or 'break'
for (const task of tasks) {
if (task.completed) break;
await processTask(task);
}
// map: when transforming data
const taskTitles = tasks.map(task => task.title);
Performance: When forEach Speed Matters (And When It Doesn't)
Here's something most developers don't think about: forEach is slower than a traditional for loop. But does that matter for your use case?
The Performance Hierarchy
From fastest to slowest:
- Traditional
forloop - Direct array access, no function calls whileloop - Similar toforloop performancefor...of- Slight overhead from iterator protocolforEach- Function call overhead for each elementmap- Function calls + new array allocation
Why forEach Is Slower
// Traditional for loop - minimal overhead
const numbers = [1, 2, 3, 4, 5];
for (let i = 0; i < numbers.length; i++) {
console.log(numbers[i]); // Direct array access
}
// forEach - adds function call overhead
numbers.forEach(num => {
console.log(num); // Callback invoked for each element
});
Every forEach callback is a function call, which adds overhead. For small arrays (under 10,000 elements), you won't notice the difference. For massive datasets or tight loops, it adds up.
Real-World Performance Impact
// Processing 10,000 user records
// Scenario 1: Small operations (logging, simple updates)
// forEach overhead: ~5-10% slower than for loop
// Real impact: milliseconds - doesn't matter
users.forEach(user => console.log(user.name));
// Scenario 2: Complex operations (API calls, heavy computation)
// forEach overhead: negligible compared to operation cost
// Real impact: none - the operation dominates timing
users.forEach(user => sendAnalyticsEvent(user));
// Scenario 3: Tight loop with millions of iterations
// forEach overhead: 30%+ slower than for loop
// Real impact: noticeable - use for loop
const results = new Array(10_000_000);
for (let i = 0; i < results.length; i++) {
results[i] = i * 2; // Use for loop here
}
When to Optimize
Use forEach when:
- Working with arrays under 10,000 elements
- The operation inside the loop is expensive (API calls, DOM updates)
- Code readability matters more than microseconds
Use traditional for loop when:
- Processing millions of elements
- In tight, performance-critical loops
- Every millisecond counts (game loops, real-time processing)
For most web applications, forEach is perfectly fine. Don't prematurely optimize.
Using async/await with forEach (The Pitfall)
This is where many developers run into trouble. forEach with async callbacks doesn't work the way you'd expect.
Why forEach Doesn't Wait
const urls = ['api/users/1', 'api/users/2', 'api/users/3'];
// This doesn't work as expected!
console.log('Starting...');
urls.forEach(async (url) => {
const response = await fetch(url);
const data = await response.json();
console.log(data);
});
console.log('Done!'); // Logs immediately, before any fetch completes
What happens:
forEachcalls the async callback for each URL- Each callback returns a Promise
forEachignores those Promises and continues- "Done!" logs before any fetch completes
- The fetches complete later, in whatever order they finish
Why it happens: forEach expects a synchronous function. It doesn't wait for Promises, and it doesn't return a Promise you can await.
Solution 1: Sequential Execution with for...of
When you need operations to happen one after another:
async function fetchUserData() {
const urls = ['api/users/1', 'api/users/2', 'api/users/3'];
console.log('Starting...');
for (const url of urls) {
const response = await fetch(url);
const data = await response.json();
console.log(data); // Logs in order: user 1, then user 2, then user 3
}
console.log('Done!'); // Logs after all fetches complete
}
Solution 2: Parallel Execution with Promise.all() + map
When operations can happen simultaneously:
async function fetchAllUserData() {
const urls = ['api/users/1', 'api/users/2', 'api/users/3'];
console.log('Starting...');
const responses = await Promise.all(
urls.map(async url => {
const response = await fetch(url);
return response.json();
})
);
console.log(responses); // All data at once, after all fetches complete
console.log('Done!');
}
When to use each:
- Sequential (
for...of): When each operation depends on the previous one, or you need to respect rate limits - Parallel (
Promise.all+map): When operations are independent and you want maximum speed
For handling async data in Convex applications, check out our guide on complex filters which demonstrates proper async data fetching patterns.
Handling Errors in a forEach Loop
Since forEach doesn't support breaking out of the loop, you'll need a specific strategy for error handling.
type UserData = {
id: string;
settings: { theme: string }
}
const users: UserData[] = [
{ id: 'user1', settings: { theme: 'dark' }},
{ id: 'user2', settings: { theme: 'light' }},
];
// Handle errors for each iteration independently
users.forEach(user => {
try {
processUserSettings(user.settings);
} catch (error) {
// Log error but continue processing other users
console.error(`Failed to process settings for ${user.id}:`, error);
}
});
// With async operations - track all errors
const processUsers = async () => {
const errorLog: string[] = [];
await Promise.all(users.map(async user => {
try {
await updateUserSettings(user);
} catch (error) {
errorLog.push(`${user.id}: ${error.message}`);
}
}));
return errorLog;
};
For managing error states in your application, check out our guide on state management without useState. When working with multiple async operations, our functional relationships helpers article shows how to handle complex data dependencies.
Stopping a forEach Loop Early
Unlike traditional loops, forEach doesn't let you break out early. If you try to use break, continue, or return, they won't work the way you expect.
const numbers = [1, 2, -3, 4, 5];
// This doesn't work - return only exits the callback
numbers.forEach(num => {
if (num < 0) return; // Only skips this iteration, doesn't stop loop
console.log(num);
});
// Still logs: 1, 2, 4, 5
// Use some() to stop early
let foundNegative = false;
numbers.some(num => {
if (num < 0) {
foundNegative = true;
return true; // Stops the loop
}
console.log(num);
return false;
});
// Logs: 1, 2 (then stops)
// Or use a traditional for loop
for (let i = 0; i < numbers.length; i++) {
if (numbers[i] < 0) break; // Actually stops the loop
console.log(numbers[i]);
}
Passing a Named Function to forEach
You can use a named function as a callback in forEach for better readability and maintenance. TypeScript's type system helps ensure your callback functions match the array's type.
interface LogEntry {
timestamp: Date;
message: string;
level: 'info' | 'warn' | 'error';
}
const logs: LogEntry[] = [
{ timestamp: new Date(), message: 'Server started', level: 'info' },
{ timestamp: new Date(), message: 'Connection failed', level: 'error' }
];
function processLogEntry(entry: LogEntry, index: number) {
const formattedTime = entry.timestamp.toISOString();
console.log(`[${index}] ${formattedTime} - ${entry.level}: ${entry.message}`);
}
// TypeScript verifies the callback signature matches LogEntry type
logs.forEach(processLogEntry);
When building data processing pipelines, consider using reduce for accumulating results or filter to select specific entries. For real-time data handling in Convex, the functional relationships guide shows how to structure your data transformations.
Using forEach with Maps and Sets
Maps and Sets in TypeScript come with their own specialized forEach implementations, each with correctly typed callbacks.
Iterating Over a Map's Entries
// Type-safe Map declaration
const userPreferences = new Map<string, { theme: string; notifications: boolean }>([
['alice', { theme: 'dark', notifications: true }],
['bob', { theme: 'light', notifications: false }]
]);
userPreferences.forEach((preferences, username) => {
console.log(`${username}'s theme: ${preferences.theme}`);
});
Iterating Over a Set's Values
// Set with union type
const validStatuses = new Set<'pending' | 'active' | 'completed'>([
'pending', 'active', 'completed'
]);
validStatuses.forEach(status => {
validateStatus(status); // TypeScript ensures status is type-safe
});
These collection types work seamlessly with Convex's state management patterns for tracking unique values and key-value relationships. For array transformations, TypeScript map and filter provide alternative ways to process your data.
Common Mistakes and Troubleshooting
Mistake 1: Trying to Return Values from forEach
// Doesn't work - forEach always returns undefined
const doubled = numbers.forEach(num => num * 2); // undefined
// Use map instead
const doubled = numbers.map(num => num * 2); // [2, 4, 6, 8]
Mistake 2: Expecting break or continue to Work
// Doesn't work
numbers.forEach(num => {
if (num > 5) break; // SyntaxError
});
// Use a traditional loop or for...of
for (const num of numbers) {
if (num > 5) break; // Works
}
Mistake 3: Modifying the Array During Iteration
const items = [1, 2, 3, 4, 5];
// Risky - modifying array while iterating
items.forEach((item, index) => {
if (item % 2 === 0) {
items.splice(index, 1); // Can skip elements or cause unexpected behavior
}
});
// Safer - filter creates a new array
const oddItems = items.filter(item => item % 2 !== 0);
Mistake 4: Confusing Side Effects vs Transformations
// Using forEach when map is better
let result = [];
numbers.forEach(num => {
result.push(num * 2); // Side effect: mutating external array
});
// More functional and clear
const result = numbers.map(num => num * 2);
Check out our TypeScript guides for more array handling patterns in full-stack applications.