Old advice says wrap less code in try/catch because it's slow, so I measured it in Node 22
A 50-million-iteration loop ran in 524.6ms plain and 502.9ms wrapped in try/catch, no real gap
Entering a try/catch that never throws cost about 0.002 microseconds per iteration
Actually throwing and catching cost about 4.06 microseconds per iteration, roughly 2,000 times more
Wrapping a hot loop in try/catch used to be something people avoided on purpose. I ran 50 million iterations plain and 50 million wrapped in try/catch on Node 22, and the wrapped version came back at 502.9 milliseconds against 524.6 for the plain loop, which isn't a penalty at all.
Where the "try/catch is slow" advice came from
The warning isn't made up. Older JavaScript engines genuinely struggled to optimize code inside a try block, and advice from that era said to keep the expensive work in a separate function and call it from inside a thin try/catch, so the optimizer could still do its job on the part that mattered.
That advice spread everywhere: style guides, interview answers, code review comments passed down without anyone checking if it still applied. It's one of those rules that keeps getting repeated past the point where it's still true, because repeating a rule is a lot less work than testing it.
I wanted a number from today's engine instead of a rule from memory.
So I built a straightforward test. A loop running 50 million times, each iteration doing a small amount of real math, Math.sqrt(i) % 7, just enough work that an empty loop body wouldn't get optimized away into nothing. One version plain, one version with the exact same body wrapped in a try/catch that never has anything to catch.
I also tested the specific workaround the old advice recommends: keep the math in a separate function, call that function from inside a thin try/catch, so the optimizer only has to deal with a function call inside the try block instead of the real work. If that workaround still mattered, the separated version should beat the inline one by a clear margin.
It didn't. The separated-function version averaged 510.3 milliseconds across five runs. The version with the math written directly inside the try block averaged 503.9. That's a 6-millisecond gap on a 50-million-iteration test, well inside the run-to-run noise I was already seeing on identical code. The workaround isn't earning its extra indirection anymore.
What I actually measured
Both versions ran five times each, timed with process.hrtime.bigint(), which Node's own documentation describes as a monotonic high-resolution clock meant for exactly this kind of measurement. A monotonic clock matters here because it's not tied to the system clock, so nothing like a clock sync in the background can make one run look artificially slow or fast.
The plain loop averaged 524.6 milliseconds across five runs: 511.9, 500.7, 587.0, 521.1, and 502.1. The try/catch version averaged 502.9 milliseconds: 530.0, 509.1, 491.7, 494.8, and 489.2.
That's not a typo. The wrapped version came back slightly faster on average, though both sets of numbers overlap enough that the honest read is "no measurable difference," not "try/catch makes code faster." Run-to-run noise on shared hardware is bigger than whatever gap exists here.
Five runs each isn't a rigorous benchmark suite. It's enough to say the old advice doesn't hold up on this engine, on this test, today. If you want more confidence than that, run it yourself. It's eleven lines of code.
Zero milliseconds of penalty for wrapping code that never throws. That's the headline, and it held up every time I reran it.
I threw in one more variant for good measure: try/finally with no catch block at all, the pattern people reach for when they want cleanup code to run but aren't handling an error. That one averaged 548.4 milliseconds, a touch higher than the plain loop's 524.6, but still inside the same noisy band every version of this test landed in. None of the three wrapped versions separated cleanly from the baseline.
The part that actually costs something
Entering a try/catch block costs almost nothing. Throwing an exception out of one is a different story entirely.
I ran a cleaner version of this test: 500,000 iterations of a try/catch that never throws, against 500,000 iterations where it throws every single time. The never-throws version finished in 0.78 milliseconds, which works out to about 0.002 microseconds per iteration, close enough to free that I'd call it free. The always-throws version finished in 2,029.4 milliseconds, about 4.06 microseconds per throw.
That's roughly 2,000 times more expensive per operation, and it has nothing to do with the try/catch syntax itself. Throwing builds a stack trace, walks up the call stack looking for a handler, and allocates an Error object, and all of that costs real time whether or not you ever read the trace.
So the actual rule isn't "avoid try/catch." It's "don't throw inside a loop you expect to run a lot," which is a much narrower and more useful piece of advice than the one that's been floating around for over a decade.
Validation code is the place I see this most. A function that throws on every invalid row in a 10,000-row import, instead of collecting invalid rows into a list and reporting them once, is paying that 4-microsecond cost 10,000 times for something that didn't need an exception at all.
Scale that up and it adds up fast without ever looking like the bottleneck in a profiler summary. 10,000 thrown exceptions at roughly 4 microseconds each is about 40 milliseconds spent just on the throwing and catching, before any of the actual validation logic runs. Forty milliseconds doesn't sound dramatic on its own, but it's forty milliseconds a straightforward array of error messages would have cost close to nothing to produce.
Parsing code runs into the same shape of problem. A JSON.parse wrapped in try/catch for one malformed response out of a thousand costs almost nothing, because the exception path runs once in a while. The same try/catch used to validate every field of every response, throwing and catching on each one, pays the expensive path on every single call instead of the rare one.
What this changes about how I write loops
I stopped structuring code around keeping try/catch out of hot paths, because that specific worry isn't buying me anything on current engines. The thing I watch for now is whether something inside the loop is actually going to throw, and how often.
That's a smaller thing to watch for than it sounds. Most loops touching external data, API responses, file reads, user input, already have a legitimate reason to catch something. The question isn't whether to wrap them. It's whether the thing inside the loop treats an exception as rare, which it should be, or as a normal part of every pass through the data, which is the part worth fixing.
An exception for something exceptional, a network call that fails once in a while, a parse that occasionally hits bad input, costs almost nothing in context because it doesn't happen on every iteration. An exception used as a control flow mechanism, thrown and caught every single time through a loop, is the pattern that's actually expensive, and it's expensive because of what throwing does, not because of the keyword wrapping it.
I'd still avoid throwing for things that aren't errors. Not because of the microseconds, but because a thrown exception used as an if statement makes code harder to read for the next person, and that cost doesn't show up in any benchmark.
The habit I'm keeping from this is simple: measure the actual thing before avoiding it out of habit. The same instinct is behind checking the exact recursion depth that crashes Node instead of just repeating "deep recursion is dangerous" without a number attached to it, and the same one behind running git bisect the same way on every search instead of trusting a method just because it usually works.
Old performance advice has a longer shelf life than the engines it was written about. JavaScript engines get rewritten and reoptimized constantly, and a rule that was true over a decade ago gets repeated today by people who read it in a blog post, not by anyone who reran the test on whatever's running now. That's not a knock on the people repeating it. Retesting an old rule takes more effort than trusting it, and most of the time there's no obvious reason to doubt it until something forces the question.
Bottom Line
The specific advice to avoid try/catch in a hot loop is outdated on a current engine. Entering one costs close to nothing. Throwing out of one costs about 2,000 times more per operation, and that's the number worth remembering instead.
If a piece of code is avoiding try/catch for speed, that's worth a second look at whether it was ever the actual bottleneck or just inherited caution from a rule nobody retested. I'd rather spend the ten minutes running the test than keep carrying a workaround that stopped paying for itself a long time ago.
The number worth keeping from all of this isn't really about try/catch specifically. It's that the cost of a language feature and the cost of what that feature does under one particular use are two different questions, and conflating them is how outdated advice survives this long.
Next time someone tells you a specific technique is slow, ask them when they last measured it.

