break/continue is the new goto

4 Aug, 2026 · 17 min read
Contents

“Go To Statement Considered Harmful” is one of the most-quoted titles in programming, and almost nobody reads past it. The argument underneath is narrower than the slogan it became — and the reflex it bred, avoiding every jump, produces code worse than the goto it was meant to replace.

break/continue is the new goto

This series has been about flattening control flow and the invariants that flat code lets us state. break, continue, and labeled jumps are part of that toolkit — structured jumps that keep the structure. Refusing them on principle is how code ends up as nested-flag spaghetti.

The title is the reflex, not the verdict.

What Dijkstra actually argued

Some history makes the argument land. Before structured programming, control flow in languages like Fortran ran on if paired with goto, and the jump targets were bare numeric labels. A clever programmer could build something that worked yet was nearly impossible to follow; every new feature or bug fix tended to add another label, and tracking what jumped where got harder with each one. The result got a name of its own — spaghetti code . The reaction was structured programming and a wave of languages that codified the common loop and branch patterns — Algol, PL/I, Pascal — while older languages grew structured statements of their own. Today essentially every language is built on those principles.

That was the backdrop for Dijkstra’s 1968 paper, Go To Statement Considered Harmful , which opens:

For a number of years I have been familiar with the observation that the quality of programmers is a decreasing function of the density of go to statements in the programs they produce.

And here is the part the slogan buried: the title is not even Dijkstra’s. He submitted “A Case Against the Goto Statement”. It ran in Communications of the ACM as a letter to the editor — and the editor was Niklaus Wirth, who would shortly give structured programming its showpiece language in Pascal, and who supplied the now-famous name (see Considered harmful ). The phrase everyone quotes was an editor’s headline.

His actual target was unrestricted goto — the kind that lets control land anywhere, so that no statement carries a knowable set of facts about how it was reached (the invariants from last time become unstateable). The arbitrary jump was the problem, not the disciplined one.

break, continue, their labeled forms, and early return are structured jumps: they can only go to a small set of well-defined places — the end of a loop, the next iteration, the end of a labeled block, the end of the function. Control still flows in one direction; we have only chosen a named exit. That is the opposite of the anywhere-to-anywhere jump Dijkstra warned about.

This post stays with break and continue, and with what they buy: code that is simpler to read and to argue about, and that skips work it does not need — both at the same time, which is not the usual trade. Other structured jumps exist, and these two have other uses; that is the slice we review.

The patterns worth keeping

break — stop when the answer is in

The plainest of them. A loop that searches has nothing left to do once it finds:

let match = null;
for (const candidate of candidates) {
  if (fits(candidate)) { match = candidate; break; }
}

Drop the break and the loop keeps testing candidates after the answer is settled — and every later iteration now has to be taught not to clobber it, either with a condition on the assignment or with a second copy of the test. The jump removes the need for both, and it states a fact the reader can use: past this line, the search is over.

A JavaScript reader will point out that this exact loop is candidates.find(fits), and it is. The built-ins cover the shapes they were designed for; we come back to what happens when the cut has to cross more than one level, which is where they run out.

continue — flatten the happy path

Its mirror image. A guard at the top of a loop body keeps the real work unindented:

for (const item of items) {
  if (!item.ready) continue;
  if (item.quarantined) continue;
  process(item);          // happy path, one level deep
}

The same logic written with nested ifs marches process(item) two or three indents to the right for no benefit. This is linearization applied to a loop body.

The same shape one scope up is the early return, and almost nobody objects to that one — guard clauses are standard advice. It is the identical structured jump, aimed at the end of the function instead of the end of the iteration.

Labeled continue — skip to the next outer iteration

Nested loops bring the labeled form. Say we count the departments that have someone named Robert — employee or contractor. The first match settles the department, and we want to move straight to the next one:

let count = 0;
outer: for (const dept of departments) {
  for (const employee of dept.employees) {
    if (employee.name === 'Robert') { ++count; continue outer; }
  }
  for (const contractor of dept.contractors) {
    if (contractor.name === 'Robert') { ++count; continue outer; }
  }
}

continue outer abandons the rest of the current department — both inner loops — and advances. Without the label the same logic has to thread a found-flag across the inner loops and re-test it between them:

let count = 0;
for (const dept of departments) {
  let isFound = false;
  for (const employee of dept.employees) {
    if (employee.name === 'Robert') { isFound = true; break; }
  }
  if (isFound) { ++count; continue; }
  for (const contractor of dept.contractors) {
    if (contractor.name === 'Robert') { isFound = true; break; }
  }
  if (isFound) ++count;
}

Same answer, twice the moving parts — and the second shape is the one I keep meeting in real code.

Labeled block — exit and skip the post-loop step

A label works on a bare block, not just a loop, which covers the case where we must leave an inner loop and also skip the processing that normally follows it:

let result;
done: {
  for (let i = 0; i < N; ++i) {
    if (winning(i)) { result = i; break done; }
  }
  result = fallback();
}
postProcess(result);

break done jumps past result = fallback(). Without the label we reach for a flag variable or split the logic across two functions — both harder to read than the one word that says “we’re done.”

do { ... } while (false) — the one still in the wild

Older code gets the same effect a different way: a loop that runs exactly once, existing solely to be a break target.

let result;
do {
  const a = compute();
  if (!a) { result = null; break; }
  const b = derive(a);
  if (!b) { result = null; break; }
  result = finalize(a, b);
} while (false);
release();   // runs either way

It reads as “linear sequence; any break jumps to the cleanup,” and it dissolves a nested-if staircase the same way the labeled block does — one flat sequence, one exit, no arrowhead of indentation. C is where the shape comes from, and for control flow it is the second choice even there: the idiomatic answer is a plain goto to a cleanup label, which the Linux kernel coding style recommends outright — “the goto statement comes in handy when a function exits from multiple locations and some common work such as cleanup has to be done.” Java never needed the trick: a label there attaches to any statement, so label: { ... break label; } works as it does in JavaScript, and the language specification presents labeled break as what Java offers instead of goto.

In JavaScript the labeled block above does the same job without pretending to be a loop, and the only thing the fake loop buys is a bare break that needs no label. Worth recognizing when we meet it; not worth reaching for.

The alternatives are usually worse

The case against structured jumps is a case for a substitute. Two are quick to dispose of. Deeper nesting — an if wrapping the rest of the body instead of a continue — buys indentation and nothing else. Extracting a function is sometimes right, but when the fragment shares several locals it trades a clean break for a long parameter list and a return protocol. The two worth a closer look are flags and array methods.

Flags

A three-level search, from code I reviewed — gadgets, their gizmos, their doodads, first acceptable item, default when nothing matches. An unlabeled break leaves only its own loop, so every level above re-tests the flag to move the exit outward:

let item = null;
for (const gadget of gadgets) {
  for (const gizmo of gadget.gizmos) {
    for (const doodad of gizmo.doodads) {
      if (acceptable(doodad)) { item = doodad; break; }
    }
    if (item) break;
  }
  if (item) break;
}
if (!item) item = useDefault();

Two of those ifs carry one bit outward, a level at a time. The third is the fallback guard, there because that bit is the only record of what happened. The labeled block states it once:

let item = null;
found: {
  for (const gadget of gadgets) {
    for (const gizmo of gadget.gizmos) {
      for (const doodad of gizmo.doodads) {
        if (acceptable(doodad)) { item = doodad; break found; }
      }
    }
  }
  item = useDefault();
}

Three conditions gone. Falling out of the loops is the proof that nothing matched, so the fallback needs no guard.

The flag also carries a bug. item is the flag, so the search rests on the result being truthy: let acceptable() accept a 0 or an empty string and the code finds it, fails its own if (item), keeps searching, and overwrites the answer with the default. break found never asks whether the item looks true.

Swapping the flags for a named break was the change I made, and it was not the whole fix. That code took several measures, and unloading it of checks that existed only to prop up the control flow — this among them — was the one that helped most.

Array methods

A JavaScript reader has a rejoinder ready: the search never needed loops.

const item = gadgets
  .map(gadget => gadget.gizmos)
  .flat()
  .map(gizmo => gizmo.doodads)
  .flat()
  .find(acceptable) ?? useDefault();

On a small structure in cold code that is the better thing to write. But every stage before find runs to completion, so the whole flattened list exists before the search starts — the exact work the labeled version exists to skip. find short-circuits over the list it was handed, at its own level. Nesting them instead does not help: each level hands one bit up to the level above — the flag again — and what the inner level found never comes back out. That is the case this post is about.

The cost is structural, not incidental. map, filter, reduce and forEach are O(n) by construction: visiting every element is their definition, and the API offers no way to stop them. Only the searching methods cut early. Chained, each stage allocates an array the next stage walks again, and every stage pays a callback call per element that a plain loop does not.

One way out exists, and it is not part of the API: a throw from inside the callback unwinds the callback and the method call together, from any depth — the only clean way to leave a forEach early. It costs a try/catch at the call site and a thrown value distinguishable from a real error. An upcoming post takes that up alongside generators.

“The examples are contrived”

Sometimes they are. The labeled block from earlier dissolves if the fallback goes first:

let result = fallback();
for (let i = 0; i < N; ++i) {
  if (winning(i)) { result = i; break; }
}
postProcess(result);

No label, and it reads fine — until fallback() is expensive, at which point computing it in order to overwrite it is the work the label avoided.

The repair for that is a sentinel, and minting one is easy. Symbol() returns a value guaranteed to be unique, which is all a sentinel needs; before it we wrote const sentinel = {}, unique by identity and just as good:

const none = Symbol();

let result = none;
for (let i = 0; i < N; ++i) {
  if (winning(i)) { result = i; break; }
}
if (result === none) result = fallback();
postProcess(result);

That works. It also costs a special value and a test to trade it back — machinery the labeled block does without, because falling out of the loop is the test.

That is the shape of the objection, and its answer. An example small enough to fit in a post can usually be restructured, because there is nothing else in it. Production code has other concerns woven through the same statements — the loop that searches also counts, or logs, or holds a lock, or must not issue a second request — and each of them pins down a piece of the structure the restructuring wanted to move. The trick that dissolves the example is often unavailable by the time it would pay.

Green cuts: jumps that only save work

The same jumps do a second job: they skip work that cannot change the answer. Remove one and the program still computes the same result, only slower — the jump is pure optimization.

That distinction has a name, borrowed from Prolog , where the cut operator prunes the search the interpreter would otherwise perform. A red cut changes what the program computes; a green cut changes only how much work it takes to reach the same answer. Green is the one we want.

Right-to-left function composition, where compose(f, g, h)(x) produces f(g(h(x))), with a bail-out sentinel — none again: once a stage yields it, the rest is pointless.

const none = Symbol();

const compose = (...fns) => x => {
  for (let i = fns.length - 1; i >= 0; --i) {
    if (x === none) return none;   // green cut
    x = fns[i](x);
  }
  return x;
};

A missed cut costs most under nesting: one skipped in an inner loop is skipped again on every pass the outer loop makes.

The fully functional spelling forgoes the cut altogether — reduceRight always walks the whole list:

const compose2 = (...fns) => x =>
  fns.reduceRight((acc, f) => acc === none ? acc : f(acc), x);

The distinction ties back to loop and if invariants . The condition that justifies bailing — that x is none — stays true for the rest of the loop, so skipping the remaining iterations cannot change the outcome. A green cut is safe by inspection; a red cut demands a proof that the jump preserves behavior.

The linter has already decided

For a lot of readers the label is not on the table at all. ESLint ships a no-labels rule, and widely-copied shared configurations switch it on — Airbnb’s sets allowLoop: false, allowSwitch: false, closing both exceptions the rule offers. Its documented rationale is that labels

tend to be used only rarely and are frowned upon by some as a remedial form of flow control that is more error prone and harder to understand.

Rarely used, and frowned upon by some. That is a headcount, not an argument — the same slogan this post opened with, only now it fails the build. The rest of this post is the answer to it, so there is no need to re-argue it here. Two details are worth noting anyway: no-labels is not part of eslint:recommended, so ESLint itself does not class labels as a default-level mistake, and the allowLoop and allowSwitch options concede that labels belong on loops and switches. Neither exception covers a label on a plain block — found: and done:, the form doing the most work here. The rule’s own sample of incorrect code is label: { break label; }.

There is a better argument for the ban than the rule makes: a construct the reader has not met stops that reader cold. It costs one visit to the language reference, paid once per reader; the flag threaded through three levels charges on every read, for as long as the code lives. Knowing the language we work in is the floor.

Where the rule is enforced and cannot be moved, // eslint-disable-next-line no-labels with a sentence saying why is what disable comments are for. Where that is a fight not worth having, lift the search into its own function and let return do the label’s job. What is not honest is pretending the flag version is equivalent.

Does this survive real code?

Here is paginateList from dynamodb-toolkit (src/mass/paginate-list.js), trimmed to its control flow — offset/limit pagination over DynamoDB, which has no native offset. We burn through the offset with COUNT-only queries, collect a page of items, then keep counting so the caller gets a total. Three phases, and the table can run dry in any of them: a page comes back without a LastEvaluatedKey, and every remaining phase is pointless.

done: {
  if (offset < 0 || limit <= 0) {
    if (needTotal) total = await getTotal(client, params);
    break done;
  }
  // Phase 1: skip `offset` items with COUNT-only queries
  while (offset - skipped > minLimit) {
    const data = await sendQueryOrScan(client, countingParams);
    total += data.Count;
    skipped += data.Count;
    // the table is exhausted
    if (!data.LastEvaluatedKey) break done;
    countingParams.ExclusiveStartKey = data.LastEvaluatedKey;
  }
  // Phase 2: collect up to `limit` items
  while (result.length < limit) {
    const data = await sendQueryOrScan(client, listingParams);
    total += data.Count;
    result = result.concat(
      data.Items.slice(0, limit - result.length)
    );
    listingParams.ExclusiveStartKey = data.LastEvaluatedKey;
    if (!data.LastEvaluatedKey) break done;
  }
  // Phase 3: keep counting for the reported total
  if (needTotal) {
    const lastKey = listingParams.ExclusiveStartKey;
    if (lastKey) countingParams.ExclusiveStartKey = lastKey;
    for (;;) {
      const data = await sendQueryOrScan(client, countingParams);
      total += data.Count;
      countingParams.ExclusiveStartKey = data.LastEvaluatedKey;
      if (!data.LastEvaluatedKey) break done;
    }
  }
}
// ... more statements that assemble the response ...

Every break done means the same thing: the data is exhausted, assemble the answer from what we have. That assembly is shared, written once, and every exit agrees to land there — four returns would copy it to four sites. Note where the jumps fire from: the first is in a bare if, outside any loop, where an unlabeled break would not even be legal; Phase 1’s skips the two phases below it, which an unlabeled break would drop straight into.

And it is not a one-off: the filtered variant in the same file repeats the same done: skeleton.

Summary

The slogan outran the paper. Unrestricted goto earns its bad name; break, continue, and labeled jumps do not — they are named exits that keep control flowing one way and keep our invariants stateable. What makes them worth knowing is that they pay twice: the code gets easier to read and to argue about, and it does less work. Those two usually trade against each other.

Three uses are worth carrying away, because they are where the substitutes hurt most:

  • A staircase of nested ifs has alternatives. A labeled block, or the older do { ... } while (false), turns the staircase into one flat sequence with one exit — and the exit can skip the post-loop step, which is the part a plain break cannot reach.
  • An exit flag carried out of nested loops has alternatives. break label and continue label cut straight out to the level that matters. The flag is not just three lines longer; it adds mutable state, one more invariant per level, and a truthiness bug waiting for its first falsy value. And no array method will do this for us — nesting them hands one bit up per level, which is the flag again.
  • A jump can be pure work saved. A green cut skips iterations that cannot change the answer, which makes it safe by inspection rather than by proof. Missed inside a loop, it is missed again on every pass of the loop outside it.

If these are rare in the code we read, that is history rather than merit: a slogan discredited them, and they stopped being taught. Knowing which instruments exist and how to use them is the job — and these were worked out for us a long time ago. Nobody is obliged to reach for a label. Today a rearrangement or an array method may serve; the day it does not, the tool should already be in hand.

break and continue are not the new goto. The reflex that treats them as though they were — quoted far more often than it is examined — is what this post is against.

Coding tactics posts

The series so far, in order:

More installments are on the way: two exotic uses of goto, and what “premature” optimization really costs.