The Web Speech API: a developer's guide to the gotchas
The SpeechSynthesis API is three lines of code that mostly work, plus a handful of gotchas that will waste your afternoon. Here is the short list, in order.
2026-06-02 · 6 min read
The speech synthesis half of the Web Speech API is small enough to learn in an afternoon. There are two objects you care about. The first is 'window.speechSynthesis', a controller that lives on the window and manages a queue of things to say. The second is 'SpeechSynthesisUtterance', which represents one chunk of text plus how it should sound. You construct an utterance with 'new SpeechSynthesisUtterance(text)', set a few properties on it, and pass it to 'speechSynthesis.speak(utterance)'. That is the entire happy path. The rest of this post is the unhappy paths, which is where you will actually spend your time.
The properties on an utterance are worth knowing precisely. Set 'utterance.text' to the string you want spoken, 'utterance.voice' to a voice object from the system, and 'utterance.lang' to a BCP 47 tag like en-US if you want the browser to pick a matching default voice. Then there are the three dials. 'utterance.rate' controls speed and accepts roughly 0.1 to 10 with a default of 1, though in practice anything above 2 or 2.5 gets hard to follow. 'utterance.pitch' runs 0 to 2, default 1. 'utterance.volume' runs 0 to 1, default 1, and is independent of the system volume. Set what you need and leave the rest at their defaults.
Now the first real gotcha, and the one that trips up everyone. You get the list of installed voices by calling 'speechSynthesis.getVoices()', and on the very first call it very often returns an empty array. This is not a bug in your code. Voices load asynchronously, and on a cold page load they are frequently not ready when your script first runs. The fix is to listen for the 'voiceschanged' event on 'speechSynthesis' and re-read 'getVoices()' inside that handler. Because the event sometimes fires before your listener attaches and sometimes after, the robust pattern is to call 'getVoices()' once immediately, and also subscribe to 'voiceschanged' so you catch the late arrival. Populate your voice picker from whichever one gives you a non-empty list first.
Each voice object carries a 'localService' flag, and it answers a question your users care about even if they never ask it. When 'voice.localService' is true, the voice runs entirely on the device and your text never leaves the machine. When it is false, the voice is served from a remote server, which means it needs a network connection and your text is sent off-box to be synthesized. On desktop Chrome the voices labeled Google are remote; the ones without that label are usually on-device. If privacy or offline support matters for your feature, filter or label your voice list by this flag rather than guessing from the name.
The utterance emits events, and they are the only way to build anything beyond fire-and-forget playback. You get 'start' when speech begins and 'end' when it finishes, which together let you drive a play or pause button that reflects reality instead of hope. You get 'error' when something goes wrong, and you should always attach a handler because a swallowed synthesis error otherwise looks like a silent, frozen UI. The most interesting one is 'boundary', which fires as the engine crosses word and sentence boundaries. It carries a character offset into the text, which is exactly what you need to highlight the current word as it is spoken. Boundary support is good in Chrome and Edge and less reliable elsewhere, so treat word highlighting as an enhancement rather than a guarantee.
Then there is the cutoff. On desktop Chrome, a single long utterance stops speaking after roughly fifteen seconds and simply goes quiet, with no error to tell you why. The underlying Chromium issue has been open for years. There are two workarounds and you should pick one deliberately. The first is to set a timer that calls 'speechSynthesis.pause()' and immediately 'speechSynthesis.resume()' every ten seconds or so, which keeps the engine awake. The second, and the one I prefer, is to split your text into sentence-sized utterances and queue them, because short utterances never hit the limit and chunking also gives you cleaner control over pausing, skipping, and progress. Wordcast splits on sentences for this reason. Either approach works; do not ship a long single utterance and assume it will finish.
iOS Safari adds its own rule that you cannot code your way around. The first 'speak()' call must happen inside a real user gesture, meaning a genuine tap or click handler. If you try to start speech on page load, from a timer, or from an async callback that has lost its connection to the original tap, iOS produces no sound and no error. Autoplay of speech is simply not allowed. The practical consequence is that your interface needs an explicit Listen button that calls 'speak()' directly in its click handler for that very first utterance. After the user has granted that initial gesture, subsequent calls in the session behave normally.
One more habit prevents a class of confusing bugs. The controller maintains a queue, so if you call 'speak()' while something is already playing, the new utterance lines up behind the old one instead of replacing it. When a user picks a new article or jumps to a new paragraph, they expect the old audio to stop, not to wait its turn. Call 'speechSynthesis.cancel()' first to clear the queue and stop current playback, then call 'speak()' with the new utterance. Making cancel-then-speak your standard entry point removes a whole category of overlapping-audio reports.
Finally, the hard limit you need to design around from the start: you cannot capture, record, or download the audio this API produces. There is no callback that hands you a buffer, no way to export an MP3, nothing to save. The Web Speech API plays speech and only plays speech. The moment your requirements include a downloadable file, an audio track for a video, or server-side rendering of narration, you have left this API's territory and need a server-side text-to-speech service instead. Knowing that boundary up front saves you from architecting around a capability that was never there. For playback in the browser, though, it costs nothing, ships everywhere, and needs no key.