Debug Mobile Regex Catastrophic Backtracking Crashes
A regular expression that looks harmless can freeze an entire mobile app. When a pattern combines nested quantifiers with untrusted input, the engine can enter catastrophic backtracking — an exponential explosion of match attempts that pins the CPU, stalls the main thread, and turns a five-line validation function into an ANR or an iOS watchdog kill. Regex denial-of-service (ReDoS) is the reason input from a login form, search box, or chat field can silently hang the very devices you ship to. In this guide we walk through how catastrophic backtracking crashes manifest in NSRegularExpression on iOS and java.util.regex on Android, how to detect them in production, and how to fix them with linear-time engines, possessive quantifiers, and input limits.
What catastrophic backtracking actually is
Most developers learn regular expressions as a matching tool and assume the engine is fast. The reality is that the common backtracking algorithm — the one powering ICU on iOS and java.util.regex on Android — has worst-case exponential running time for a whole class of patterns. The engine tries one path, fails near the end of the string, backtracks to the last decision point, and tries again. When a pattern has multiple quantifiers that can each be satisfied many ways, the number of paths explodes.
The canonical example is (a+)+$ matched against a long run of a characters followed by a b. For aaaaaaaaaaaaaaaaaaaaaaaaaaaa!, the engine explores an astronomical number of ways to partition the a run before finally giving up. A string of only 30 characters can require billions of steps. This is regular expression denial of service, and it is the root cause of some of the most famous outages in web history — including the Cloudflare outage of July 2019 and the Stack Overflow outage of July 2016, both traced to a single catastrophic regex.
On a server you can at least restart a worker. On a phone, the same bug looks very different: the main thread locks up, the UI stops responding, and the operating system kills your app.
iOS: NSRegularExpression and the watchdog kill
Apple's NSRegularExpression is a thin wrapper over the ICU regex engine, and it has one glaring omission for mobile developers: there is no timeout parameter. Once you call firstMatch(in:options:range:) or matches(in:options:range:), the call is synchronous and blocks the calling thread for as long as the engine needs. Run it on the main thread against adversarial input, and you have a frozen app.
The 0x8badf00d signature
When the main thread is unresponsive, the iOS watchdog eventually terminates the process and writes a crash report with exception code 0x8badf00d — "ate bad food." If your crash dashboard is full of 0x8badf00d reports whose stack traces point into NSRegularExpression, _NSRegexMatch, or a rangeOfFirstMatch frame rather than your own slow loop, catastrophic backtracking is the likely culprit. We cover the watchdog mechanism itself in our guide on fixing iOS watchdog termination crashes.
import Foundation
// Dangerous: an untrusted email string hits a pattern that can backtrack exponentially
func isValidEmail(_ input: String) -> Bool {
let pattern = #"^([a-zA-Z0-9_\-\.]+)@((\[[0-9]{1,3}\.){3}[0-9]{1,3}\])$"#
let regex = try! NSRegularExpression(pattern: pattern)
let range = NSRange(input.startIndex..., in: input)
// Blocking call with no timeout — freezes the main thread on bad input
return regex.firstMatch(in: input, options: [], range: range) != nil
}The nested quantifiers — ([a-zA-Z0-9_\-\.]+) followed by the bracketed-IP group with its own repetition — create multiple ways to split the local part. A long, malformed string that is mostly @ and digits can send ICU into a backtracking storm. The try! is a second, unrelated bug: a malformed pattern crashes instead of throwing.
Fixing it on iOS
There is no official timeout on NSRegularExpression, so you have to build the safety margin yourself. The most robust approach is to move matching off the main thread entirely and wrap it in a cancellable task, then cap the input length before the regex ever sees it:
func safeEmailMatch(_ input: String) -> Bool {
// Cap untrusted input before it reaches the engine
guard input.count <= 254 else { return false }
let regex = try! NSRegularExpression(pattern: #"^[^@\s]+@[^@\s]+\.[^@\s]+$"#)
let range = NSRange(input.startIndex..., in: input)
return regex.firstMatch(in: input, options: [], range: range) != nil
}The replacement pattern avoids nested quantifiers on overlapping character classes, so it stays linear on the input. For patterns you cannot simplify, run the match on a background queue and use NSMatchingReportCompletion only when you truly need progress callbacks — the completion handler still cannot interrupt a single match that is already spinning.
Android: java.util.regex and the ANR
Android's Pattern and Matcher use a backtracking engine with the same exponential worst case, and it ships with an even harder constraint: there is no timeout API at all. matcher.find(), matcher.matches(), and the convenience String.matches() all block the calling thread until the engine finishes.
The classic ANR pattern
When that blocking call runs on the main thread — inside onCreate, a TextWatcher, or an afterTextChanged callback — the input dispatcher stops getting responses, the system posts an "Application Not Responding" dialog, and Android kills your app. If your ANR reports consistently point at java.util.regex.Matcher.match or String.matches, the fix is not "add more threads" — it is that the regex itself is the bottleneck.
import java.util.regex.Pattern;
public class Validator {
private static final Pattern UNSAFE =
Pattern.compile("^(a+)+$"); // exponential backtracking on input like aaaa...b
public boolean unsafeCheck(String input) {
// Blocks the main thread; long "a" runs + trailing char = ANR
return UNSAFE.matcher(input).matches();
}
}StackOverflowError in the regex engine
A second, sneakier failure mode on Android is a StackOverflowError thrown deep inside the regex engine. Some backtracking implementations recurse on deeply nested groups, and a pattern with many nested quantifiers can blow the call stack on long input. A StackOverflowError from java.util.regex is almost never about your code — it is the engine recursing into an adversarial pattern. We cover the general class of these failures in our stack overflow and recursion crash guide.
Kotlin and Dart/Flutter: same engine, same risk
Kotlin's Regex class is a thin wrapper over java.util.regex, so every caveat above applies unchanged. The idiomatic "input".matches(Regex(...)) and Regex(...).find(input) calls block the calling thread and inherit the same exponential worst case.
val unsafe = Regex("^(a+)+$")
fun validate(input: String): Boolean = unsafe.matches(input) // same backtracking riskDart and Flutter are no safer. Dart's RegExp also uses a backtracking engine and offers no timeout parameter on hasMatch, firstMatch, or allMatches. A regex passed to TextFormField validation that runs on the UI isolate can freeze the entire Flutter UI just as effectively as an Android ANR.
final RegExp unsafe = RegExp(r'^(a+)+$');
bool validate(String input) => unsafe.hasMatch(input); // freezes the UI isolate on bad inputThe cross-platform takeaway is simple: the regex engine is a shared, non-linear dependency, and none of the mainstream mobile regex APIs give you a timeout. You have to enforce the boundary yourself.
ReDoS is a security problem, not just a crash
It is worth naming the class of bug precisely. ReDoS is a denial-of-service attack: an attacker supplies a string that is short — often under a hundred characters — but that drives your regex into exponential work. Because the payload is tiny, input-length checks alone can miss it. The OWASP ReDoS entry catalogues the dangerous pattern families: nested quantifiers, overlapping alternations, and any group followed by * or + that overlaps what the group itself matches.
In a mobile app, the "attacker" is frequently just an ordinary user pasting a long ID, or a malformed value arriving from a third-party API. The impact is identical to a network DoS: your app is unusable for that user, and if the regex runs during a shared initialization path, it can take down the whole session.
Detecting regex hangs in your own app
Regex hangs do not throw exceptions, which is what makes them hard to find. You have to look for the side effects. On iOS, watch for clusters of 0x8badf00d watchdog terminations whose stack traces bottom out in NSRegularExpression frames. On Android, watch for ANRs that point at java.util.regex.Matcher — and treat them separately from the ordinary main-thread I/O ANRs we discuss in our Android ANR detection guide.
Profiling closes the loop. In Instruments, a Time Profiler trace that shows a single NSRegularExpression call consuming the majority of a sample window is a smoking gun. On Android, a Perfetto or Systrace capture will show the main thread parked in Matcher.match for seconds at a time. Once you have identified the regex, feed it progressively longer adversarial strings in a unit test and watch the wall-clock time curve — a healthy regex scales linearly, while a catastrophic one shows a hockey-stick curve.
Prevention: make your regexes linear
The durable fixes fall into a short list. First, prefer a linear-time engine where you can. On Android, RE2/J is a drop-in java.util.regex replacement that guarantees linear-time matching by refusing to backtrack; it simply does not support backreferences or lookaround, which is a fair trade for predictable performance. There is no direct iOS equivalent, but you can enforce linearity by construction.
Second, eliminate the backtracking in the pattern itself. Use atomic groups and possessive quantifiers so the engine commits to a match and never revisits it. In Java and ICU, a possessive quantifier like a++ or an atomic group (?>a+) tells the engine not to backtrack into that construct.
// Atomic group: the engine will not backtrack into the a+ once it commits
private static final Pattern SAFE = Pattern.compile("^(?>a+)$");Third, cap input length before matching. No validation regex needs megabytes of input; reject anything over a sane limit up front. Fourth, pre-compile your patterns once instead of recompiling on every keystroke — Pattern.compile itself has cost, and a TextWatcher that recompiles per character compounds the problem. Fifth, move matching off the main thread when it must handle untrusted input, and gate it with a watch that cancels and reports the event if a match exceeds your budget.
Finally, remember that the engine's worst case is what bites you, not its average case. A regex that benchmarks fine on normal input can still be catastrophic on crafted input, so test it with the adversarial strings from the OWASP catalogue, not just the happy path.
Stop regex hangs before your users uninstall
Catastrophic backtracking is one of the few crash classes that is entirely preventable at review time — but only if you know to look for it. Audit every regex that touches user input, kill nested quantifiers, cap input length, and move untrusted matching off the main thread. Where you cannot simplify, reach for a linear-time engine like RE2/J and accept the loss of backreferences as the cost of safety.
When a hang does ship, you need the signal to find it fast: the watchdog exception codes, the ANR traces pointing into Matcher.match, and the structured context that tells you which field and which pattern froze. That is exactly what Bugspulse captures on every mobile error, so a regex that was burning a user's battery for 30 seconds shows up as a diagnosable event instead of a one-star review. Ready to catch these hangs the moment they happen? Start your free Bugspulse account and attach full context to every crash, ANR, and watchdog kill.