Skip to main content
JavaScript · lesson 6 of 24
JavaScript beginner Lesson 6 of 24

Control Flow in JavaScript

Learn how to direct your program's execution with if/else, switch, loops (for, while, for...of, for...in), break, continue, and labeled statements.

By SkillByExample · Updated

Conditional Statements

Control flow is what makes programs useful: instead of running the same sequence of statements every time, your code can make decisions, repeat work, and skip steps based on conditions. Every meaningful program — from a login check to a data pipeline — relies on control flow to behave differently in different situations.

if / else if / else

The if statement is the most fundamental control structure. It evaluates a condition and runs a block of code only when that condition is truthy. Chaining else if handles multiple branches, and a final else catches everything that didn’t match. The condition is coerced to a boolean — any truthy value takes the if branch.

const score = 74;

if (score >= 90) {
  console.log("Grade: A");
} else if (score >= 80) {
  console.log("Grade: B");
} else if (score >= 70) {
  console.log("Grade: C");
} else {
  console.log("Grade: F");
}
$ node score.js
Grade: C

The condition score >= 70 is true (74 ≥ 70) — the first matching branch wins, so the result is C. Each earlier condition was checked first and failed: 74 >= 90 is false, 74 >= 80 is false, so execution lands on the third else if and runs its body.

Always use braces {} even for single-line bodies — omitting them is a common source of subtle bugs when adding a second statement later.

Truthy and falsy in conditions

Because JavaScript coerces any value to boolean in a condition, you can write concise guards without explicit comparisons. The key is knowing which values are falsy — 0, "", null, undefined, NaN, and false — and which aren’t. Empty arrays and empty objects are truthy, which surprises many beginners.

const username = "";

// "" is falsy, so this triggers
if (!username) {
  console.log("Please enter a username");
}

// Careful: empty array and empty object are truthy
const items = [];
if (items) {
  console.log("This runs — [] is truthy");
}
if (items.length > 0) {
  console.log("This doesn't run — length is 0");
}
$ node truthy.js
Please enter a username
This runs — [] is truthy

"" is one of JavaScript’s six falsy values, so !username is true and the first message prints. Meanwhile, items is [] — an array with zero elements — but an empty array is still an object, and all objects are truthy regardless of their contents. That’s why the second if runs without printing “This doesn’t run”: items.length > 0 is false.

switch Statement

switch is useful when you need to compare a single value against many possible cases. It compares using strict equality (===), so there’s no type coercion. Always include break at the end of each case — without it, execution “falls through” into the next case, which is one of the most common sources of bugs in JavaScript.

const day = "Monday";

switch (day) {
  case "Saturday":
  case "Sunday":
    console.log("Weekend");
    break;
  case "Monday":
  case "Tuesday":
  case "Wednesday":
  case "Thursday":
  case "Friday":
    console.log("Weekday");
    break;
  default:
    console.log("Unknown day");
}
$ node switch.js
Weekday

The switch compares "Monday" against each case using strict equality (===). It skips "Saturday" and "Sunday" (no match), lands on "Monday" (match), and because there’s no break on that case, it falls through the remaining weekday cases until it reaches the shared console.log("Weekday") — that shared body is what runs.

Intentional fallthrough

Sometimes fallthrough is exactly what you want — when multiple cases should share cumulative behavior. When you do this deliberately, always add a comment so reviewers know it wasn’t an oversight.

// Accumulating permissions by role — fallthrough is deliberate here
switch (role) {
  case "admin":
    permissions.push("delete");
    // falls through
  case "editor":
    permissions.push("write");
    // falls through
  case "viewer":
    permissions.push("read");
    break;
  default:
    permissions.push("none");
}

The switch pitfall: no strict type check on the expression

const input = "1";

switch (input) {
  case 1:    // number 1 — won't match "1"
    console.log("number one");
    break;
  case "1":  // string "1" — this runs
    console.log("string one");
    break;
}
$ node strict-switch.js
string one

Ternary Operator

For simple inline conditionals (covered in detail in the operators tutorial):

const label  = user.isAdmin ? "Admin" : "User";
const plural = count === 1 ? "item" : "items";

for Loop

The classic for loop gives you full control over iteration: an initializer, a condition checked before each step, and an update expression run after each step. It’s best when you know the number of iterations upfront or when you need the numeric index for something other than just accessing elements.

for (let i = 0; i < 5; i++) {
  console.log(i); // 0 1 2 3 4
}

// Counting backwards
for (let i = 10; i > 0; i -= 2) {
  console.log(i); // 10 8 6 4 2
}

// Iterating an array by index (useful when you need the index itself)
const fruits = ["apple", "banana", "cherry"];
for (let i = 0; i < fruits.length; i++) {
  console.log(`${i}: ${fruits[i]}`);
}
$ node for-demo.js
0
1
2
3
4
10
8
6
4
2
0: apple
1: banana
2: cherry

while Loop

A while loop runs as long as its condition is truthy and is the right choice when you don’t know the number of iterations in advance. Connection retries, polling loops, and processing queues are all natural fits. Always ensure something inside the loop eventually makes the condition false, or the loop runs forever.

let attempts = 0;
const MAX = 3;

function tryConnection(attempt) {
  return attempt === 2; // succeeds on the 3rd attempt
}

let connected = false;
while (attempts < MAX) {
  connected = tryConnection(attempts);
  if (connected) break;
  attempts++;
}
console.log(`attempts: ${attempts}, connected: ${connected}`);

// Polling until a queue is empty
const queue = [10, 20, 30];
const processed = [];
while (queue.length > 0) {
  const job = queue.shift();
  processed.push(job * 2); // pretend "processing" doubles the value
}
console.log(processed);
$ node while-demo.js
attempts: 2, connected: true
[ 20, 40, 60 ]

The connection loop stops as soon as tryConnection returns true — here that happens on the third attempt (attempts reads 2 because it hasn’t been incremented yet when the successful attempt breaks the loop). The queue loop calls shift() until nothing is left, doubling each value as it “processes” it — queue ends up empty and processed holds the transformed jobs.

Watch out for infinite loops — make sure something inside the loop eventually makes the condition false.

do…while Loop

A do...while loop runs its body first, then checks the condition — guaranteeing at least one execution regardless of whether the condition is true. This is the right tool when the action must happen before you can even evaluate whether to continue, such as prompting a user for input or displaying a menu.

// Guaranteed first run — do...while always executes the body once
let count = 0;
do {
  count++;
} while (count < 0); // condition is false, but the body still ran once
console.log(count);

// Menu-driven programs: do...while fits because you always show the menu
// at least once. Simulated here with a queued list instead of a real prompt().
const inputs = ["view", "edit", "quit"];
let i = 0;
let choice;
do {
  choice = inputs[i++];
  console.log(`Selected: ${choice}`);
} while (choice !== "quit");
$ node dowhile-demo.js
1
Selected: view
Selected: edit
Selected: quit

count starts at 0, and the loop body always runs at least once — count becomes 1 even though count < 0 is false before the body ever executes. The menu loop pulls from a queued list of inputs instead of a real prompt() (which needs a terminal or browser to exist), but the guarantee is the same: the first choice is read and handled before the while condition gets a say, which is exactly why do...while fits a “show the menu, then decide whether to loop” flow.

for…of — Iterating Values

for...of is the modern, idiomatic way to iterate over any collection. It works on arrays, strings, Maps, Sets, generators, and anything that implements the iterator protocol — without the footguns of for...in on arrays. When you just need each element, prefer for...of over a classic for loop.

const colors = ["red", "green", "blue"];

for (const color of colors) {
  console.log(color); // "red", "green", "blue"
}

// Works on strings — iterates individual characters
for (const char of "hello") {
  console.log(char); // "h", "e", "l", "l", "o"
}

// With index via entries() — best of both worlds
for (const [index, color] of colors.entries()) {
  console.log(`${index}: ${color}`);
}

// Iterating a Map gives [key, value] pairs
const userRoles = new Map([["Alice", "admin"], ["Bob", "editor"]]);
for (const [user, role] of userRoles) {
  console.log(`${user} is ${role}`);
}
$ node foreachof.js
red
green
blue
h
e
l
l
o
0: red
1: green
2: blue
Alice is admin
Bob is editor

for...of gives you values directly — strings are iterable so "hello" yields each character. The entries() method on arrays returns an iterator of [index, value] pairs, letting you have both without a separate counter. For Maps, for...of yields [key, value] pairs natively, so destructuring on the loop variable gives you both cleanly.

for…in — Iterating Keys

for...in iterates over the enumerable string property keys of an object. It’s designed specifically for plain objects when you need to loop over their keys dynamically. Avoid using it on arrays — it iterates index strings, not numeric indices, and also picks up any inherited properties added to Array.prototype.

const config = {
  host: "localhost",
  port: 5432,
  database: "mydb"
};

for (const key in config) {
  console.log(`${key}: ${config[key]}`);
}
$ node forin.js
host: localhost
port: 5432
database: mydb

for...in walks the object’s enumerable keys in insertion order, so each iteration binds key to "host", "port", then "database" — config[key] looks up the matching value each time.

Why not to use for…in on arrays

const arr = ["a", "b", "c"];

for (const key in arr) {
  console.log(key, typeof key);
}

// It also iterates inherited properties if someone extended Array.prototype.
// Use for...of or forEach for arrays instead.
$ node forin-array.js
0 string
1 string
2 string

Every key for…in gives you on an array is a string — "0", not the number 0 — because for...in is designed for object property keys, and JavaScript array indices are just string keys under the hood. Comparing key === 0 would silently fail since "0" === 0 is false; you’d need key === "0" or Number(key) === 0.

for…of vs for…in — Quick Reference

for…offor…in
IteratesValuesKeys (strings)
Works onArrays, strings, Map, Set, iterablesPlain objects, arrays (avoid)
Array indexNo (use .entries())Yes, but as string
Inherited propsNoYes (use hasOwn to filter)

break and continue

break and continue give you fine-grained control inside a loop. break exits the loop entirely — useful when you’ve found what you need and further iterations would be wasted work. continue skips the rest of the current iteration and jumps straight to the next one — useful for filtering out unwanted items without nesting an if around the whole body.

// break — stop as soon as you find the target
const users = [
  { id: 1, name: "Alice" },
  { id: 2, name: "Bob" },
  { id: 3, name: "Charlie" }
];

let found = null;
for (const user of users) {
  if (user.id === 2) {
    found = user;
    break; // no point iterating further
  }
}

// continue — skip negatives, collect positives
const numbers = [1, -2, 3, -4, 5];
const positives = [];

for (const n of numbers) {
  if (n < 0) continue; // skip this iteration
  positives.push(n);
}
$ node break-continue.js
[1, 3, 5]

break stops the loop entirely once the target is found — the remaining elements are never checked. continue skips negatives but keeps the loop going, so positives ends up as [1, 3, 5] — only the positive values from the original array.

Labeled Statements

Labels let you break out of or continue a specific outer loop when dealing with nested loops — something a plain break can’t do. They’re rarely needed in practice, and if you find yourself reaching for them regularly, it’s usually a sign that the nested logic should be extracted into its own function.

// Without labels, break only exits the innermost loop
outer: for (let i = 0; i < 3; i++) {
  for (let j = 0; j < 3; j++) {
    if (i === 1 && j === 1) {
      break outer; // exits both loops at once
    }
    console.log(`${i},${j}`);
  }
}
$ node labeled.js
0,0
0,1
0,2
1,0

Without the label, break would only exit the inner loop and i would continue to 1, then 2 — logging every combination. With break outer, the moment i === 1 and j === 1 is true, both loops terminate at once, so the pair 1,1 and everything after it is never logged.

Combining Control Flow — A Real Example

Real programs combine these structures together. The early-return pattern, continue for skipping invalid data, and a final conditional are all common idioms that keep the happy path readable without deep nesting.

function getStock(productId) {
  const stock = { p1: 10, p2: 0, p3: 5 };
  return stock[productId] ?? 0;
}

// Process an order: validate, apply discount, check stock
function processOrder(order) {
  // Early return pattern — handle errors first, keep the main path clean
  if (!order) return { error: "No order provided" };
  if (!order.items?.length) return { error: "Order has no items" };

  let total = 0;
  const unavailable = [];

  for (const item of order.items) {
    if (item.quantity <= 0) continue; // skip invalid quantities

    const stock = getStock(item.productId);
    if (stock < item.quantity) {
      unavailable.push(item.productId);
      continue; // can't fulfill this item, track it and move on
    }

    total += item.price * item.quantity;
  }

  if (unavailable.length > 0) {
    return { error: `Out of stock: ${unavailable.join(", ")}` };
  }

  // Apply a 10% discount on orders over $100
  const discount = total > 100 ? 0.1 : 0;
  return { total: total * (1 - discount), discount };
}

console.log(processOrder({
  items: [
    { productId: "p1", price: 20, quantity: 6 },
    { productId: "p3", price: 15, quantity: 2 },
  ],
}));

console.log(processOrder({
  items: [{ productId: "p2", price: 50, quantity: 1 }],
}));
$ node process-order.js
{ total: 135, discount: 0.1 }
{ error: 'Out of stock: p2' }

The first order totals 20*6 + 15*2 = 150, crosses the $100 discount threshold, and returns 135 after the 10% cut. The second order asks for one unit of p2, whose stock is 0 — continue skips adding it to the total and records it in unavailable, so the function short-circuits to an error instead of silently returning a total of 0. This pattern — validate early, loop with continue to skip bad data, return a result — is idiomatic in production JavaScript.

Practice

  1. Leap year checker: Write a program that checks if a year is a leap year. A year is a leap year if it’s divisible by 4, but not by 100, unless it’s also divisible by 400. Use if/else if/else and test with years 2020, 1900, and 2000.

  2. FizzBuzz: Print numbers from 1 to 30. For multiples of 3, print “Fizz” instead of the number. For multiples of 5, print “Buzz”. For multiples of both 3 and 5, print “FizzBuzz”.

  3. Array search with early termination: Given an array of products [{id: 1, price: 10}, ...], write a for...of loop that finds the first product with price over 50 and breaks. Print the product or “none found”.

Solution 1 ```js const year = 2000;

if (year % 400 === 0) { console.log(${year} is a leap year); } else if (year % 100 === 0) { console.log(${year} is not a leap year); } else if (year % 4 === 0) { console.log(${year} is a leap year); } else { console.log(${year} is not a leap year); }

</details>

<details><summary>Solution 2</summary>
```js
for (let i = 1; i <= 30; i++) {
  if (i % 15 === 0) {
    console.log("FizzBuzz");
  } else if (i % 3 === 0) {
    console.log("Fizz");
  } else if (i % 5 === 0) {
    console.log("Buzz");
  } else {
    console.log(i);
  }
}
Solution 3 ```js const products = [ { id: 1, price: 10 }, { id: 2, price: 25 }, { id: 3, price: 75 }, { id: 4, price: 200 }, ];

let found = null; for (const product of products) { if (product.price > 50) { found = product; break; } }

if (found) { console.log(“Found:”, found); } else { console.log(“none found”); }

</details>



With control flow covered, you have the tools to write programs that make decisions and repeat tasks. The next tutorials cover functions — where you package this logic for reuse.

Frequently Asked Questions

What is the difference between for...of and for...in?
for...of iterates over the values of any iterable (arrays, strings, Maps, Sets). for...in iterates over the enumerable property keys of an object. Using for...in on an array is a common mistake — it iterates over index strings like '0', '1', and also inherited properties. Use for...of for arrays.
Do I always need a break in a switch statement?
Yes, unless you intentionally want fallthrough. Without break, execution continues into the next case — a very common source of bugs. Some teams use ESLint's no-fallthrough rule to catch this automatically.
When should I use while instead of for?
Use while when you don't know how many iterations you need upfront — polling until a condition is met, reading lines until EOF, or retrying until success. Use for when you know the iteration count or are looping over a collection.