Fail Fast — Professional Level¶
Category: Control-Flow Patterns — the runtime mechanics of failing fast: assertion semantics, exception/panic cost, branch prediction of guards, and what the compiler does with your checks.
Table of Contents¶
- Introduction
- Assertion Semantics Across Languages
- The Cost of a Guard That Never Fires
- Exception, Panic, and Error Cost
- Go panic/recover Internals
- Compiler-Eliminated Checks
- Fail Fast in Concurrent Code
- Benchmarks
- Diagrams
- Related Topics
Introduction¶
Failing fast is cheap on the happy path and expensive only when it fires — which is exactly the cost profile you want. At the professional level you should be able to:
- Explain why a
requireNonNullguard is essentially free after branch prediction warms up. - Predict when the JVM/Go compiler elides a check you wrote because it can prove the condition.
- Quantify the cost of throwing vs returning an error vs panicking.
- Know which assertions survive to production and which vanish.
Assertion Semantics Across Languages¶
The single most important professional fact about fail-fast: assertions are not validation in most languages, because they're conditionally compiled out.
| Language | Mechanism | Enabled by default? | Stripped in prod? |
|---|---|---|---|
| Java | assert cond : msg; | No — needs -ea | Yes (no -ea) |
| Python | assert cond, msg | Yes | Yes under python -O |
| Go | (no built-in assert) | — | — (use explicit if + panic) |
| C/C++ | assert() (<cassert>) | Yes (debug) | Yes under NDEBUG |
| Rust | assert! / debug_assert! | assert! always; debug_assert! debug-only | debug_assert! only |
Consequences:
- Java:
assert user != nulldoes nothing in a normally-launched production JVM. Use it for internal sanity checks in tests/dev; useObjects.requireNonNullor an explicitthrowfor real validation. - Python: never guard security or input validation with
assert—-Oremoves it, and any__debug__-stripped check silently becomes a no-op. - Go: there is no
asserton purpose; you writeif !cond { panic(...) }, which always runs. This is deliberate fail-fast ergonomics — the check can't be compiled away. - Rust:
assert!always fires (good for invariants you want in release);debug_assert!is the "expensive sanity check" that disappears in release.
The Cost of a Guard That Never Fires¶
A fail-fast guard on the happy path is one comparison and a never-taken branch:
What the hardware sees:
- Branch prediction. The branch is never taken in practice, so after a few iterations the CPU predicts "not taken" with ~100% accuracy. A correctly-predicted branch costs effectively zero cycles (it overlaps with other work).
- Cold throw path. The
throwblock is laid out off the hot path (the JIT/compiler moves it to a cold section), so it doesn't pollute the instruction cache. - No allocation on success. The exception object is only allocated when the branch is taken.
Net cost on the happy path: a single integer compare, perfectly predicted ≈ 0–1 cycle. This is why "validate every public argument" carries no measurable runtime penalty — the guards are free until they're needed.
The exception: guards with expensive predicates (a regex match, a collection scan, a hash) cost their predicate every call, fired or not. Keep guard predicates cheap, or gate the expensive ones behind a debug flag (debug_assert!-style).
Exception, Panic, and Error Cost¶
The cost is entirely on the failure path; pick the mechanism by how often it fires.
Java exceptions¶
new IllegalArgumentException("msg") // ~allocation + message
.fillInStackTrace() // THE expensive part — walks the stack
fillInStackTrace (called by the constructor) is the dominant cost — typically 1–10 µs depending on stack depth. Throwing on a genuinely rare error path is fine. Throwing in a hot loop as control flow is an anti-pattern (orders of magnitude slower than a returned status).
Optimization for hot, expected failures: override
fillInStackTraceto returnthis(stackless exception), or use a returnedOptional/result type instead of an exception.
Go errors vs panic¶
- Returning
erroris nearly free: it's a value (an interface, two words). Constructingerrors.New("...")allocates a small struct;fmt.Errorfallocates a formatted string. No stack walk. panicunwinds the stack running deferred functions; cost is comparable to a Java throw (stack-dependent). Reserve it for impossible states.
This is why idiomatic Go uses error returns for the common recoverable case and panic only for programmer errors — the cost model matches the intent.
Python exceptions¶
Raising is moderately cheap (~hundreds of ns), but Python's try/except setup on the happy path is near-zero in modern CPython (zero-cost try since 3.11). So try/except for the rare-failure case is idiomatic and fast; raising in a tight loop is not.
Go panic/recover Internals¶
Mechanics:
panicsets a_panicrecord on the goroutine and begins unwinding, running deferred functions in LIFO order.- A deferred function calling
recover()returns the panic value and stops the unwind; execution resumes after the deferred function's caller. - If no
recoveris found, the unwind reaches the top of the goroutine and the whole process aborts with a stack dump — not just that goroutine.
Performance notes:
- Deferred-function cost improved dramatically in Go 1.14 (open-coded defers): a
deferin a function with no panic in flight is nearly free, so therecoverboundary is cheap to install. recoveronly works in a deferred function directly — calling it elsewhere returnsnil.- Because an unrecovered panic kills the process, each long-lived goroutine needs its own recover boundary (see senior's supervisor pattern).
Compiler-Eliminated Checks¶
Sometimes the compiler proves your fail-fast check is redundant and removes it — or inserts its own.
JVM: implicit null checks¶
The JVM doesn't need an explicit if (x == null) to fail fast on a null dereference. x.foo() compiles to a memory access that traps on a null page; the JVM converts the hardware SIGSEGV into a NullPointerException at zero happy-path cost. So x.foo() on a null x already fails fast — Objects.requireNonNull(x, "x") exists to fail earlier and with a better message, not to add safety the runtime lacks.
JIT bounds-check elimination¶
Java checks every array access for bounds (a fail-fast guarantee). HotSpot's bounds-check elimination proves i stays in range for this loop and removes the per-iteration check — you keep the safety guarantee with none of the runtime cost.
Go: nil-check and bounds-check elimination¶
Go similarly inserts nil and bounds checks (fail-fast by spec) and elides them when it can prove safety (-gcflags=-d=ssa/check_bce/debug=1 shows which survive). The language guarantees the fail-fast behavior; the compiler optimizes away the cost where provable.
The lesson: language-level fail-fast (null deref, array bounds) is "free safety" — the compiler removes the check when it can and keeps it when it can't, and either way bad state never silently propagates.
Fail Fast in Concurrent Code¶
Java fail-fast iterators¶
ArrayList's iterator tracks a modCount; structural modification during iteration bumps it, and the next next() fails fast with ConcurrentModificationException. This is best-effort (not guaranteed under data races) — it exists to surface a bug (mutating while iterating) close to its cause, not as a thread-safety mechanism.
Memory visibility¶
A fail-fast check on a field written by another thread without synchronization may read a stale value and either fire spuriously or miss the violation. Fail-fast guards on shared mutable state need the same volatile/atomic/lock discipline as any other read — otherwise the guard itself is racy.
Panics across goroutines¶
As noted, a panic in goroutine B cannot be recovered by goroutine A. Fail-fast in concurrent Go means a recover boundary per goroutine, or the first unhandled panic takes the process down.
Benchmarks¶
Apple M2 Pro, single thread. Indicative, not authoritative.
Happy-path guard cost (Java, JMH)¶
Benchmark Mode Cnt Score Units
noGuard avgt 10 0.30 ns/op
requireNonNull (passes) avgt 10 0.31 ns/op // ~free, predicted branch
rangeCheck if (n<0) (passes) avgt 10 0.30 ns/op // ~free
regexGuard (passes) avgt 10 85.0 ns/op // expensive predicate, every call
Failure-path cost¶
throw IllegalArgumentException (with stack) ~2.5 µs
throw stackless (fillInStackTrace overridden) ~40 ns
Go: return errors.New(...) ~30 ns (alloc)
Go: panic + recover (shallow stack) ~1.0 µs
Python: raise ValueError (caught) ~400 ns
Takeaways:
- Guards that pass are free; spend the cost only when something is actually wrong.
- A throw/panic on a rare path is negligible; as control flow in a loop it's catastrophic.
- In Go, prefer
errorreturns for expected failures (30 ns) overpanic(1 µs) — both correctness and speed agree.
Diagrams¶
Cost lives on the failure path¶
Java assert vs requireNonNull lifecycle¶
Related Topics¶
- JVM internals: Java Performance: The Definitive Guide — bounds-check elimination, implicit null checks.
- Go runtime: "Pardon the Interruption: Loop Preemption" and the Go spec on
panic/recover/defer. - Branch prediction: Agner Fog's microarchitecture manuals.
- Compile-time fail-fast: Type-Safe Enums.
← Senior · Control-Flow Patterns · Next: Interview
In this topic