← Back to writing

How I Fixed an Android App That Froze After Every Reboot

Our classroom panels showed "App isn't responding" about 50 seconds after every restart. 93% of those freezes traced back to Android's text-to-speech, and the last one came from just creating it.

  • Android
  • ANR
  • Binder
  • Performance

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, onDone and onError only touch state if that id is still the one on screen. With a single shared id, a late onDone from 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’s speak() returns quickly once the engine is up. Its setters and stop() 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

  1. TextToSpeech calls are synchronous binder IPC under a lock. Keep every one of them off the main thread.
  2. The constructor alone starts a setup task that can stall on a cold engine. Construct lazily.
  3. Key asynchronous callbacks by the request they belong to, not a shared id.
  4. A small number of devices producing thousands of events means a loop. Break the loop.
  5. Cross-process accessibility queries block. Read them on their own thread.

Further reading