Rehoboth Builds

chrome.tts can fire start, stay silent, and never fire error

The failure that costs you an afternoon is not the one that throws. It is the call that reports success, emits its start event, and produces no audio — because every check you would think to write is already green. Here is what we measured while building a page reader, and the watchdog we shipped because of it.

7 September 2026

The measurement

On 6 September 2026, on one machine, we sent the same sentence through two voices.

With Google US English — a network voice — the start event arrived 23.5 seconds after the speak() call. There was no audio. There was no end event, ever. And critically there was no error event: the callback that would have told us something was wrong never ran. With Google Deutsch, on the same machine, in the same session, the same code worked normally.

We are reporting one machine on one day, so treat the numbers as an existence proof rather than a rate: we did not measure how often this happens, and we are not going to guess. What the observation does establish is the shape of the failure, and the shape is the actionable part — the absence of an error event is not evidence that audio is playing. If your code treats "no error" as "speaking", it will hang forever on exactly this case.

Why a state machine built on the events alone deadlocks

The natural design is a queue: speak an utterance, wait for end, advance to the next one. That design has three exits — end, error, or the user pressing stop. The failure above takes none of them. You get start, which in most implementations flips the state to "playing" and disables the play button, and then nothing arrives to flip it back. The extension is not crashed and not stuck in a loop; it is correctly waiting for an event that is never coming.

This is why the fix cannot live inside the event handlers. There is no handler to put it in. It has to be a timer that exists outside the state machine and is allowed to overrule it.

The watchdog we shipped

Ours is deliberately unclever: after speak(), if no start arrives within seven seconds, we stop waiting, fall back to the system default voice, and tell the user in the UI that we switched. Three properties of that choice were the whole point:

  • It watches for start, not for end. A long paragraph legitimately takes a long time to finish, so a timeout on end has to be proportional to the text and will either fire spuriously or far too late. Time-to-first- audio has no legitimate reason to be long.
  • It degrades rather than reports. An error toast asks the user to diagnose a voice engine, which they cannot do. Falling back to a voice that is installed locally makes the product work, and the message explains the change rather than the failure.
  • It says what it did. Silently swapping the voice is the same class of bug as the one we are fixing: the user hears a different voice than the one they picked and has no way to learn why.

Seven seconds is a judgement call from one observation of 23.5, not a measured optimum. It needs to be comfortably longer than a healthy network voice's startup and comfortably shorter than a user's patience.

Three neighbouring cases worth knowing before you meet them

getVoices() can return an empty list, and speak() will still accept your call. We see this routinely on Linux, where no speech engine is installed. Nothing throws. There is simply no sound. So an empty voice list is worth checking for explicitly and surfacing as its own message, because it is the one silent failure with a genuinely different cause — and a different answer for the user.

An invalid lang value is rejected outright. If you feed the page's lang attribute straight through — a reasonable thing to want, so the engine picks a voice matching the content — remember that attribute is author-controlled and frequently malformed. We validate it before passing it on. Likewise an empty string as the utterance is an error, so a sentence splitter that can emit a blank segment will break the queue at the point where the queue looks fine.

Long inputs have a ceiling. A single utterance tops out in the region of 32 KB of text. In practice you will split long before that for other reasons — we split per sentence so the reader can highlight the sentence being spoken — but if you are streaming a whole article into one call, the limit is real.

The MV3 half of the problem

One more thing that looks like a speech bug and is not. Under Manifest V3 the extension's background context is a service worker that Chrome may reclaim when it looks idle, and during a long read it does look idle. We hold it open with a keepalive on a 20 second interval.

The symptom when this goes wrong is distinctive enough to be diagnostic: the voice keeps talking and the highlight stops moving. Audio is owned by the speech engine and survives; the code that advances your UI does not. If you see that specific combination, look at the worker's lifetime before you look at the TTS API.

What to take away

Treat the speech engine as a remote service that can accept a request and then go quiet, because for network voices that is exactly what it is. Concretely: put a timer on time-to-first-audio, never treat the absence of error as success, and have a locally installed voice to fall back to.

All of the above came out of building a reader that speaks a page and highlights each sentence as it goes. It is submitted to the Chrome Web Store and in review as of 7 September 2026; what it does, what it cannot read, and what it will cost is written out plainly on its pre-order page, including the limitations we have not solved.