Control Flow and Loops
Your Code Needs to Make Decisions
Every program you write does three things: stores data, makes decisions, and repeats work. You already know how to store data with variables. Now you need to learn how JavaScript decides which code to run and how to repeat code without writing it a hundred times.
Control flow is the backbone of every program. Without it, your code would run line by line from top to bottom, doing the exact same thing every single time. That's not a program — that's a recipe.
Think of your code as a road trip. Without control flow, you're on a straight highway with no exits — start to finish, one path. if/else gives you forks in the road where you pick a direction. Loops give you roundabouts where you keep circling until you've done what you need. break is the exit ramp off the roundabout, and continue is skipping a stop without leaving the roundabout entirely.
if, else if, and else
The most fundamental decision in programming: "if this is true, do that."
const temperature = 35;
if (temperature > 30) {
console.log("It's hot outside");
} else if (temperature > 20) {
console.log("Nice weather");
} else if (temperature > 10) {
console.log("Grab a jacket");
} else {
console.log("Stay inside");
}
// Output: "It's hot outside"
JavaScript evaluates each condition top to bottom and runs the first block that matches. Once a match is found, it skips everything else — even if later conditions would also be true.
const score = 95;
if (score > 50) {
console.log("Pass"); // This runs
} else if (score > 90) {
console.log("Excellent"); // Never reached — first condition already matched
}
This is a common trap. If you want to check the most specific condition first, put it at the top:
const score = 95;
if (score > 90) {
console.log("Excellent"); // Now this runs
} else if (score > 50) {
console.log("Pass");
}
Truthy and Falsy Values in Conditions
The condition inside if () doesn't have to be a boolean. JavaScript coerces any value to true or false. Only 7 values are falsy:
if (false) {} // falsy
if (0) {} // falsy
if (-0) {} // falsy
if (0n) {} // falsy
if ("") {} // falsy
if (null) {} // falsy
if (undefined) {} // falsy
if (NaN) {} // falsy
// Everything else is truthy — including:
if ("0") {} // truthy (non-empty string)
if ([]) {} // truthy (empty array is still an object)
if ({}) {} // truthy (empty object is still an object)
Empty arrays and empty objects are truthy. This trips up everyone at least once. if ([]) runs the block. if ({}) runs the block. If you want to check for an empty array, use if (arr.length === 0). For an empty object, use if (Object.keys(obj).length === 0).
if ("false") {
console.log("hello");
}
switch/case
When you're comparing one value against several possibilities, switch is cleaner than a chain of if/else if:
const day = "Tuesday";
switch (day) {
case "Monday":
console.log("Start of the work week");
break;
case "Tuesday":
case "Wednesday":
case "Thursday":
console.log("Midweek");
break;
case "Friday":
console.log("Almost weekend");
break;
default:
console.log("Weekend");
}
// Output: "Midweek"
Fall-Through Behavior
Here's the thing most people miss about switch: without break, execution falls through to the next case. This isn't a bug — it's by design, and sometimes useful:
const fruit = "apple";
switch (fruit) {
case "apple":
console.log("It's an apple");
// No break! Falls through to next case
case "banana":
console.log("It's a fruit");
break;
case "carrot":
console.log("It's a vegetable");
break;
}
// Output:
// "It's an apple"
// "It's a fruit"
Without break after the "apple" case, JavaScript keeps running the next case's code regardless of whether it matches. The Tuesday/Wednesday/Thursday example above uses this intentionally — all three share the same output.
switch compares using ===, not ==. So case "1" will NOT match the number 1. This catches people who pass numbers to a switch but write string cases (or vice versa).
When to Use switch vs if/else
Use switch when you're comparing one value against discrete constants. Use if/else when you need range checks, complex conditions, or different variables:
// Good for switch — discrete values
switch (status) {
case "loading": /* ... */ break;
case "success": /* ... */ break;
case "error": /* ... */ break;
}
// Bad for switch — ranges need if/else
if (score >= 90) { /* ... */ }
else if (score >= 70) { /* ... */ }
else { /* ... */ }
Loops — Repeating Work
The for Loop
The classic loop. Three parts: initialization, condition, update.
for (let i = 0; i < 5; i++) {
console.log(i);
}
// 0, 1, 2, 3, 4
Let's trace exactly what happens at each step:
The order matters: check → body → update. The update runs after the body, not before. And the check runs before the body, so if the condition is false from the start, the body never runs at all.
while Loop
When you don't know how many iterations you need upfront:
let attempts = 0;
let connected = false;
while (!connected) {
attempts++;
connected = tryConnect();
console.log(`Attempt ${attempts}`);
}
A while loop checks the condition before each iteration. If the condition is false initially, the body never executes.
do...while Loop
Guarantees the body runs at least once, because it checks the condition after the first iteration:
let input;
do {
input = prompt("Enter a number greater than 10:");
} while (Number(input) <= 10);
This is useful when you need to execute something before you can check whether to continue — like getting user input or making an initial network request.
for...of vs for...in — The Critical Difference
This is one of the most confused topics in JavaScript. These two loops look similar but do fundamentally different things.
for...of — Iterates Over Values
for...of works with iterables: arrays, strings, Maps, Sets, NodeLists, arguments, generators — anything with a Symbol.iterator method.
const colors = ["red", "green", "blue"];
for (const color of colors) {
console.log(color);
}
// "red", "green", "blue"
const name = "Hello";
for (const char of name) {
console.log(char);
}
// "H", "e", "l", "l", "o"
for...in — Iterates Over Keys
for...in iterates over the enumerable property keys of an object — including inherited ones from the prototype chain:
const user = { name: "Alice", age: 30, role: "admin" };
for (const key in user) {
console.log(`${key}: ${user[key]}`);
}
// "name: Alice", "age: 30", "role: admin"
Why You Should Almost Never Use for...in on Arrays
const arr = ["a", "b", "c"];
for (const index in arr) {
console.log(typeof index, index);
}
// "string" "0"
// "string" "1"
// "string" "2"
Three problems with for...in on arrays:
- Keys are strings, not numbers. The index
"0"is not the same as0. - It includes inherited enumerable properties. If someone adds to
Array.prototype, those show up. - Order is not guaranteed for all cases (though modern engines do maintain insertion order for array indices).
// This is dangerous — someone else's code added to the prototype
Array.prototype.customMethod = function() {};
const items = ["a", "b"];
for (const key in items) {
console.log(key);
}
// "0", "1", "customMethod" — surprise!
// for...of doesn't have this problem
for (const item of items) {
console.log(item);
}
// "a", "b" — only the actual values
Plain objects are NOT iterable. You cannot use for...of directly on an object. Trying for (const val of myObject) throws a TypeError. Use Object.keys(), Object.values(), or Object.entries() to get an iterable from an object, then loop over that.
const config = { host: "localhost", port: 3000 };
// This throws: config is not iterable
// for (const val of config) {}
// Instead, use Object.entries()
for (const [key, value] of Object.entries(config)) {
console.log(`${key}: ${value}`);
}
// "host: localhost", "port: 3000"
| Feature | for...of | for...in |
|---|---|---|
| Iterates over | Values | Enumerable property keys |
| Works on arrays | Yes (recommended) | Technically yes, but don't |
| Works on objects | No (throws TypeError) | Yes (designed for this) |
| Key type | N/A (gives values) | Always strings |
| Includes inherited? | No | Yes (from prototype chain) |
| Use when | Arrays, strings, Maps, Sets | Object properties only |
break and continue
break — Exit the Loop Entirely
break immediately stops the loop and jumps to the code after it:
const numbers = [1, 3, 7, 12, 5, 22, 8];
for (const num of numbers) {
if (num > 10) {
console.log(`Found ${num}, stopping`);
break;
}
console.log(`Checked ${num}`);
}
// "Checked 1"
// "Checked 3"
// "Checked 7"
// "Found 12, stopping"
continue — Skip This Iteration
continue skips the rest of the current iteration and jumps to the next one:
for (let i = 0; i < 10; i++) {
if (i % 2 !== 0) continue;
console.log(i);
}
// 0, 2, 4, 6, 8 — only even numbers
With continue, the loop doesn't stop — it just moves to the next iteration. The update expression (i++) still runs.
Labeled Statements — Controlling Nested Loops
When you have loops inside loops, break and continue only affect the innermost loop. What if you want to break out of the outer loop from inside the inner one? That's what labels are for.
const matrix = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]
];
let target = 5;
let found = false;
outerLoop:
for (let row = 0; row < matrix.length; row++) {
for (let col = 0; col < matrix[row].length; col++) {
if (matrix[row][col] === target) {
console.log(`Found ${target} at [${row}][${col}]`);
found = true;
break outerLoop;
}
}
}
// "Found 5 at [1][1]"
// Without the label, break would only exit the inner loop
Without break outerLoop, the inner break would only exit the column loop, and the row loop would continue to the next row. The label lets you jump out of both loops at once.
continue works with labels too — it skips to the next iteration of the labeled loop, not just the current one:
outer:
for (let i = 0; i < 3; i++) {
for (let j = 0; j < 3; j++) {
if (j === 1) continue outer;
console.log(`i=${i}, j=${j}`);
}
}
// "i=0, j=0"
// "i=1, j=0"
// "i=2, j=0"
// j never reaches 2 — continue outer skips to the next i
Labels are rare in production code. Most of the time, you can restructure the logic into a function and use return instead. But for searching through multi-dimensional data or parsing algorithms, labels are cleaner than flag variables.
Loop Performance Considerations
For most code, loop performance doesn't matter — the difference between loop types is negligible. But when you're iterating over thousands of items or inside hot paths, small things add up.
Cache the Length
Accessing .length on every iteration isn't expensive for arrays (V8 optimizes it), but for live NodeLists it can be:
const elements = document.querySelectorAll(".item");
// querySelectorAll returns a static NodeList — length is fine
for (let i = 0; i < elements.length; i++) {
// elements.length is checked each iteration,
// but for static NodeLists this is optimized away
}
// getElementsByClassName returns a LIVE HTMLCollection
const liveItems = document.getElementsByClassName("item");
const len = liveItems.length;
for (let i = 0; i < len; i++) {
// Caching length matters here — live collections
// recalculate length when the DOM changes
}
Avoid Work Inside the Loop That Belongs Outside
// Wasteful — regex is compiled every iteration
for (let i = 0; i < items.length; i++) {
const pattern = /^user_\d+$/;
if (pattern.test(items[i])) { /* ... */ }
}
// Better — compile once
const pattern = /^user_\d+$/;
for (let i = 0; i < items.length; i++) {
if (pattern.test(items[i])) { /* ... */ }
}
Prefer for...of for Readability, Classic for When You Need the Index
const items = ["a", "b", "c"];
// When you just need values — cleaner, no off-by-one bugs
for (const item of items) {
process(item);
}
// When you need the index
for (let i = 0; i < items.length; i++) {
processWithIndex(items[i], i);
}
// Or use entries() for both
for (const [index, item] of items.entries()) {
processWithIndex(item, index);
}
Do array methods like .forEach() and .map() replace loops?
Array methods are fantastic for data transformation — they're declarative, chainable, and impossible to mess up with off-by-one errors. But they come with tradeoffs:
- You can't
breakorcontinueout of.forEach(). If you need early exit, use afor...ofloop. .map()and.filter()create new arrays. In a hot loop over massive data, this allocates memory the classicforloop doesn't.awaitinside.forEach()doesn't work as expected — the iterations run in parallel, not sequentially. Usefor...ofwithawaitinstead.
The rule of thumb: use array methods for transforming data, use for...of when you need flow control (break, continue, await), and use the classic for loop when you need raw index access or are in a performance-critical hot path.
Putting It All Together
Here's a practical example that combines multiple control flow concepts — processing a list of tasks with different priorities:
const tasks = [
{ id: 1, title: "Deploy fix", priority: "critical" },
{ id: 2, title: "Update docs", priority: "low" },
{ id: 3, title: "Code review", priority: "high" },
{ id: 4, title: "BLOCKED", priority: "critical" },
{ id: 5, title: "Write tests", priority: "medium" },
];
const processed = [];
for (const task of tasks) {
if (task.title === "BLOCKED") continue;
switch (task.priority) {
case "critical":
console.log(`URGENT: ${task.title}`);
processed.push(task.id);
break;
case "high":
console.log(`Important: ${task.title}`);
processed.push(task.id);
break;
default:
console.log(`Queued: ${task.title}`);
}
}
// URGENT: Deploy fix
// Queued: Update docs
// Important: Code review
// Queued: Write tests
| What developers do | What they should do |
|---|---|
| Using for...in to loop over arrays for...in gives string keys, includes inherited properties, and order is not guaranteed for all cases | Use for...of for arrays, for...in only for object properties |
| Forgetting break in switch cases Missing break causes silent fall-through to the next case, which is one of the most common bugs in switch statements | Always add break unless you intentionally want fall-through, and add a comment when you do |
| Using var instead of let in for loops var is function-scoped, not block-scoped, which causes the classic closure-in-loop bug where all callbacks share the same variable | Always use let (or const in for...of) in loop declarations |
| Comparing with == in switch cases switch(1) with case '1' will NOT match because strict equality requires the same type — no coercion happens | Remember that switch uses === (strict equality) |
- 1if/else if evaluates top to bottom — put the most specific condition first
- 2switch uses === (strict equality) and falls through without break
- 3for...of iterates values of iterables (arrays, strings, Maps, Sets)
- 4for...in iterates enumerable string keys of objects (including inherited ones)
- 5Never use for...in on arrays — use for...of or a classic for loop
- 6break exits the loop entirely, continue skips to the next iteration
- 7Labels let you break or continue outer loops from inside nested loops
- 8Use for...of with await for sequential async iteration, never .forEach()