AI-powered crash analysis is now available on all plans — including Free.Read the crash analysis guide

Debug Speech Recognition Crashes on iOS & Android

NFNourin Mahfuj Finick··9 min read

Speech recognition is one of the most fragile surfaces in a modern mobile app. When a user taps the microphone and the app dies mid-dictation, you have a speech recognition crash on your hands — and it is almost never a single bug. It is a chain: a permission denied at runtime, a recognition task that outlives its delegate, an audio session stolen by the music player, or a RecognitionListener callback fired on the wrong thread. This guide walks the full crash surface on iOS (SFSpeechRecognizer) and Android (SpeechRecognizer), with code-level fixes and the production signals that catch these failures before your users file a one-star review.

Why dictation crashes differently than other features

Speech recognition is special because it sits at the intersection of three systems that normally never talk to each other: the microphone and audio hardware, a platform voice service (Apple's on-device/dictation service or Google's speech service), and your app's own UI thread. A crash in any one of those layers manifests as a crash in your app, which is why naive crash reports for dictation features so often point at the wrong frame. Before you read a single stack trace, understand that these crashes break into four buckets: permission denials, task/request lifecycle mistakes, audio session and focus conflicts, and threading violations. We'll cover each on both platforms.

iOS: SFSpeechRecognizer crash debugging

Check availability before you touch the API

The most common iOS speech crash is also the most preventable: calling into SFSpeechRecognizer when it isn't available. isAvailable returns false when the device locale lacks a recognition model, when the user has disabled dictation in Settings, or when the network is unreachable for on-server recognition. Instantiating a recognizer and starting a task anyway throws an exception you can't catch cleanly.

let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))
guard let recognizer = recognizer, recognizer.isAvailable else {
    // Surface a graceful fallback; never let this path crash
    showDictationUnavailable()
    return
}
recognizer.delegate = self

Also verify the locale you request actually resolves. SFSpeechRecognizer(locale:) can return nil for unsupported locales, and force-unwrapping that optional is a textbook crash. The SFSpeechRecognizer documentation is explicit that availability can change at runtime, so re-check it in viewWillAppear rather than caching it once at launch.

Request permission and read the denial path

Speech recognition requires two Info.plist keys — NSMicrophoneUsageDescription and NSSpeechRecognitionUsageDescription. Missing either key causes an immediate, unrecoverable crash when the permission prompt would have appeared, and it is the single most common cause of "works in the simulator, crashes on device" reports.

<key>NSMicrophoneUsageDescription</key>
<string>We use the microphone to transcribe your voice notes.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Speech recognition converts your dictation into text.</string>

Request authorization explicitly with requestAuthorization and handle the .denied, .restricted, and .notDetermined cases. The callback arrives on an arbitrary queue, so hop back to the main queue before mutating UI — a dispatch mistake here is itself a frequent crash source.

SFSpeechRecognizer.requestAuthorization { status in
    DispatchQueue.main.async {
        switch status {
        case .authorized: self.startListening()
        case .denied, .restricted, .notDetermined:
            self.showPermissionDenied()
        @unknown default: break
        }
    }
}

Respect the task lifecycle: cancel vs finish

Every SFSpeechRecognizer recognition runs inside an SFSpeechRecognitionTask. The two most dangerous lifecycle bugs are calling cancel() when you mean finish(), and touching a task after it has been released. cancel() aborts immediately and discards partial results; finish() tells the recognizer to stop accepting new audio and return a final result. If your UI holds a strong reference to the task and also retains the result handler, you build a retain cycle that keeps the audio engine alive long after the user left the screen — a leak that eventually exhausts the shared audio resources and crashes the next dictation attempt.

var recognitionTask: SFSpeechRecognitionTask?
 
func stopListening() {
    audioEngine.stop()
    recognitionRequest?.endAudio()
    // finish() for a clean final result; cancel() only for hard abort
    recognitionTask?.finish()
    recognitionTask = nil
    recognitionRequest = nil
}

Note the explicit = nil on both properties. This is the difference between a feature that survives backgrounding and one that crashes on the tenth use.

Own the audio session and engine

SFSpeechAudioBufferRecognitionRequest feeds PCM buffers from an AVAudioEngine into the recognizer. The crash here is almost always a shared-resource conflict: your app activates an AVAudioSession category that the engine didn't configure, or you start the engine twice without stopping it first. Configure the session to .record, route the tap, and guard against double-start.

let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers)
try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
 
let inputNode = audioEngine.inputNode
inputNode.removeTap(onBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 1024, format: inputNode.outputFormat(forBus: 0)) { buffer, _ in
    self.recognitionRequest?.append(buffer)
}
audioEngine.prepare()
try audioEngine.start()

Watch two things in production: setActive(_:) can throw if another session (a VoIP call, a navigation prompt) refuses to yield, and installTap will throw if a tap already exists on the bus. Wrap both in do/catch and log the error instead of crashing.

Threading and the result handler retain cycle

Apple delivers recognition results on a serial queue, not the main thread. Reading bestTranscription on the main thread while the callback is still writing to it is a classic data race. More insidious is the retain cycle: the result handler captures self strongly, and self owns the task that owns the handler. Use a weak capture and marshal UI updates explicitly.

recognitionTask = recognizer.recognitionTask(with: request) { [weak self] result, error in
    guard let self = self else { return }
    if let result = result {
        let text = result.bestTranscription.formattedString
        DispatchQueue.main.async { self.transcriptLabel.text = text }
    }
    if let error = error { self.handleRecognitionError(error) }
}

Finally, respect SFSpeechRecognitionTaskHint. Passing .dictation when the audio is actually a short command, or .search when the user is dictating a paragraph, changes how aggressively the engine buffers and finalizes results. Misusing the hint doesn't always crash directly, but it produces late or missing final results that push users to hammer the mic button — and repeated rapid start/stop cycles are exactly what trigger the audio-engine crashes above.

Android: SpeechRecognizer crash debugging

Binding, intents, and the RecognizerIntent

On Android the crash surface starts before recognition begins. SpeechRecognizer.createSpeechRecognizer(context) returns null when no speech service is present on the device (common on AOSP builds and some OEM ROMs), and calling setRecognitionListener on a null instance is an immediate NullPointerException. Check for SpeechRecognizer.isRecognitionAvailable(context) first, and always build your RecognizerIntent with a language model and a package you trust.

if (!SpeechRecognizer.isRecognitionAvailable(context)) {
    showDictationUnavailable()
    return
}
val recognizer = SpeechRecognizer.createSpeechRecognizer(context)
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
    putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
    putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
}
recognizer.setRecognitionListener(listener)
recognizer.startListening(intent)

Read every onError code — they are not the same

The RecognitionListener surface reports failures through onError, and each code points at a different root cause. ERROR_INSUFFICIENT_PERMISSIONS means RECORD_AUDIO was not granted; ERROR_RECOGNIZER_BUSY means a previous session is still holding the microphone; ERROR_NO_MATCH and ERROR_SPEECH_TIMEOUT mean the recognizer ran but returned nothing; ERROR_CLIENT is the catch-all you must treat as a real bug, not ignore.

override fun onError(error: Int) {
    when (error) {
        SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> requestMicPermission()
        SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> scheduleRetry()
        SpeechRecognizer.ERROR_NO_MATCH, SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> showNoSpeechHeard()
        SpeechRecognizer.ERROR_CLIENT -> reportClientError()
    }
}

A silent onError swallow is the number-one reason Android dictation "randomly stops working" — the recognizer is still bound, still holding the mic, and the next tap crashes with ERROR_RECOGNIZER_BUSY or a leaked-service exception.

The RECORD_AUDIO permission race

RECORD_AUDIO is a runtime permission, which means there is a window between your "start dictation" tap and the permission actually being granted. If you call startListening while the permission dialog is still up, the recognizer fires ERROR_INSUFFICIENT_PERMISSIONS, and if you call it twice in quick succession (user double-taps), you get the busy error above. Gate recognition on the permission state and disable the mic button until the grant lands. For a fuller treatment of runtime-permission crash patterns, see our runtime permission debugging guide.

Unbind and destroy, or leak the service

SpeechRecognizer is a bound service, and the single most damaging Android bug in this space is forgetting to tear it down. If you never call cancel(), destroy(), or stopListening(), the recognizer keeps the microphone open and the service bound for the life of your process, which on low-memory devices gets your app killed and on every device crashes the next recognition attempt. Do the cleanup in onDestroy — and remember the listener itself must not hold a strong reference to a destroyed Activity.

override fun onDestroy() {
    super.onDestroy()
    recognizer?.cancel()
    recognizer?.destroy()
    recognizer = null
    audioManager?.abandonAudioFocus(focusChangeListener)
}

Main-thread violations and mic conflicts

RecognitionListener callbacks arrive on the main thread by default, but the buffer callbacks and any custom audio routing you bolt on do not. Posting a TextView update from a background thread throws CalledFromWrongThreadException, and calling startListening from a worker thread can crash the recognizer's internal state. Keep every recognizer call on the main thread and use a coroutine or Handler for anything that must hop. Meanwhile, if your app also plays audio — a podcast, a call, a game — you must release and reacquire audio focus around dictation or the two features will fight over the microphone and the loser crashes. This is the same failure family we detail in our mobile audio crash guide.

Cross-platform: the four crash patterns to watch

Strip away the platform APIs and four patterns account for nearly every speech recognition crash on both platforms. First, microphone permission race conditions: both OSes deliver permission results asynchronously, and both punish you for starting recognition before the grant. Second, audio session and focus conflicts between dictation and playback — dictation is not a playback feature, but it shares the same audio pipeline, so you must yield and reacquire focus explicitly. Third, threading of recognition callbacks: iOS results arrive off-main, Android results arrive on-main, and both frameworks crash when you assume the opposite. Fourth, memory leaks from long-lived tasks: a recognition task that survives its screen leaks the audio engine, the service binding, or both, and the crash you eventually see is just the last symptom. For the race-condition half of this, our thread safety guide covers the detection tooling in depth.

Monitor it in production

A speech recognition crash rarely reproduces in the lab because it depends on the user's locale, their audio state, and their exact tap timing. That's why the fix isn't complete until you can see it in production. Watch for the cluster of ERROR_CLIENT and ERROR_RECOGNIZER_BUSY events that precedes an actual crash, track the permission-denial rate on the dictation screen, and correlate audio-session exceptions with the recognizer lifecycle. Bugspulse surfaces these as first-class error groups — so a retain cycle in your result handler and a leaked Android service show up as named, deduplicated issues instead of a wall of anonymous stack traces. When dictation breaks, you'll know which platform, which error code, and which session state triggered it — and you'll fix it before the next release ships.

Ready to see your speech recognition crashes in production, not in app-store reviews? Create a free Bugspulse account and start triaging dictation failures with real session context in minutes.