The question
A panel on my desk running our AI assistant had a strange habit. About 50 seconds after every reboot, it showed “App isn’t responding”. Then it did it again on the next boot.
Crashlytics said this wasn’t one panel. For September, about 90% of the app’s ANRs fell into two clusters on the main thread: text-to-speech, and accessibility queries.
My initial mental model
TextToSpeech is a high-level API. You construct it, wait for onInit, then call speak(). Speaking is
asynchronous, which is the whole point. Calling setLanguage() or stop() from the main thread should
be cheap: set a field, maybe send a message.
None of that is true while the engine is starting.
What actually happens
TextToSpeech is a client for a separate engine service, usually Google’s TTS, in another process.
Most of its methods are synchronous binder calls into that service. They’re made while holding the
framework’s connection lock, the same lock the setup code holds while it connects to the engine.
On a classroom panel, the Google TTS engine starts cold at boot, and it took 15–20 seconds to answer. During that window:
boot ──► overlay service starts ──► new TextToSpeech(...)
│ framework setup task takes the
│ connection lock and waits for the
│ cold engine (15–20 s)
main thread: tts.setLanguage() / tts.stop()
└─► blocks on the same lock
└─► input not handled for 5 s ──► ANR
The chat controller called stop() when it reset its state, and onInit set the language and speech
rate, all on the main thread. Each of those calls waited for the engine’s setup to finish. The main thread
was stuck behind another app’s cold start.
Let’s look at the implementation
Fix one: one thread for the engine
Every call into TextToSpeech now goes through a single dedicated engine thread. That includes
construction callbacks, language and rate, speak and stop. The main thread only posts work to it:
// Simplified. All engine IPC lives on one thread, never on main.
private val engine =
Executors.newSingleThreadExecutor { Thread(it, "tts-engine") }
fun stop() {
// Nothing was ever queued: don't touch the engine at all.
if (activeMessageId == null) return
engine.execute { tts?.stop() }
}
Two smaller changes came with it:
stop()became a no-op when nothing was queued. Resetting a chat no longer reaches into a service that might still be starting.- Callbacks are keyed by message id. The utterance id is now the chat message’s id, and
onStart,onDoneandonErroronly touch state if that id is still the one on screen. With a single shared id, a lateonDonefrom a replaced message could clear the state of the newer one.
The accessibility cluster had the same shape. Reading another window’s node tree (window.root,
findAccessibilityNodeInfosByViewId, getChild) is a blocking cross-process query with a five-second
timeout, and our service ran its window reader and a 500 ms YouTube poll on Dispatchers.Main, the
same thread the overlay draws with. Both readers moved to a dedicated reader thread.
On the panel, two reboots in a row produced no ANR, and the process stayed alive through the cold-TTS window.
The surprising part: constructing it was enough
While fix one waited for a release, I pulled every ANR from the build already in the field. 93% of ANR events were in one place:
TextToSpeech$SetupConnectionAsyncTask … getDefaultVoiceNameFor(…)
That’s not our code calling the engine. It’s the framework’s own setup task, started by the
TextToSpeech constructor, stuck asking the cold engine for its default voice. Fix one wouldn’t
have touched it.
Our chat manager was created when the overlay service started, and the overlay service starts at boot.
So every boot constructed TextToSpeech, and every construction started that setup. Moving our calls off
the main thread couldn’t help, because the stall was inside the framework, triggered by the object
existing at all.
The numbers made it worse than it looked. Most of those events came from about a dozen panels. The pattern
is a loop: the service ANRs, the system kills it, START_STICKY restarts it, it constructs TextToSpeech
again, and the cycle repeats while the engine is still cold. The same setup task could also crash the
process on a bad parcel from the engine, on a framework thread we couldn’t wrap in a try.
The fix was to stop constructing it at boot. The engine is now created lazily by a warmUp() that runs the
first time a teacher sends a message or opens a conversation. That’s long after boot, and only if speech
might be needed. If initialisation fails, the instance is released, so the next warmUp() retries instead
of leaving the app silent.
// Simplified. Called from sendMessage / loadConversation, never at boot.
fun warmUp() {
if (tts != null) return
tts = TextToSpeech(appContext) { status ->
if (status != TextToSpeech.SUCCESS) release() // next warmUp() retries
}
}
Nothing binds at boot any more, which removes the ANR loop and the uncatchable framework crash together.
Practical consequences
- “Asynchronous API” doesn’t mean “cheap to call”.
TextToSpeech’sspeak()returns quickly once the engine is up. Its setters andstop()are synchronous IPC, and all of them can block while it isn’t up. - Constructing a system-service client can start work you can’t control. If that work can stall, don’t construct the client until you need it, and never at boot.
- Boot is the worst time for everything. Every engine and service is cold and competing. A component that starts at boot should do nothing at boot that it could do later.
- Look at events per device, not just events. Thousands of events from a dozen panels is a restart loop, not a common bug. It needs a different fix: stop restarting into the failure.
- Accessibility tree reads are IPC too. Treat them like network calls: off the main thread, with a budget.
What I don’t have yet is the fleet’s ANR rate on the release with both fixes. On the panel the reboot ANR is gone, and that number will confirm it at scale.
What I would remember
TextToSpeechcalls are synchronous binder IPC under a lock. Keep every one of them off the main thread.- The constructor alone starts a setup task that can stall on a cold engine. Construct lazily.
- Key asynchronous callbacks by the request they belong to, not a shared id.
- A small number of devices producing thousands of events means a loop. Break the loop.
- Cross-process accessibility queries block. Read them on their own thread.
Further reading
TextToSpeechandUtteranceProgressListener.- ANRs and how input-dispatch timeouts are measured.
Service.START_STICKY.