DEV Community

Cover image for The 'Read Aloud' Button That Only Works on the Second Click
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

The 'Read Aloud' Button That Only Works on the Second Click

A support ticket lands: "The 'listen to this article' button did nothing. I clicked it, waited, clicked it again — nothing. No error in the console either." You open the page to check. The button reads the article back to you immediately, in a clean voice, first click, every time.

You close the ticket as "unable to reproduce" and move on. It reopens two days later from a different user, same symptom. That's when you notice the thing you'd missed: you have never once tested this feature on a page you hadn't already reloaded a dozen times that session. Every one of your test runs had a warm browser. Real visitors don't.

The four lines everyone writes first

Here's the version that looks completely reasonable, and is what most tutorials show:

function speak(text) {
  const voices = speechSynthesis.getVoices();
  const utterance = new SpeechSynthesisUtterance(text);
  utterance.voice = voices.find((v) => v.name.includes("Google US English")) || voices[0];
  speechSynthesis.speak(utterance);
}

document.querySelector("#read-aloud").addEventListener("click", () => speak(articleText));
Enter fullscreen mode Exit fullscreen mode

speechSynthesis is a real object, sitting on window in every major browser, with a full text-to-speech engine behind it — no API key, no network request, no cost. getVoices() returns an array of installed voices, you pick one, you call speak(). It reads like it should just work.

Guess before you scroll: on the very first click, after a genuinely fresh page load, how many voices does voices actually contain?

Zero. Or close to it. Not because the browser has no voices installed — because it hasn't finished asking its speech engine for the list yet.

Why "just call getVoices()" is a race you usually win by accident

speechSynthesis.getVoices() doesn't fetch anything when you call it — it just returns whatever the browser has populated so far. The actual enumeration (asking the OS or an embedded engine which voices exist) happens asynchronously, off on its own timeline, and on a cold page load it frequently hasn't finished by the time your click handler runs a few hundred milliseconds after the page painted.

The first time you call it, you can easily get []. voices.find(...) on an empty array returns undefined, voices[0] on an empty array is also undefined, and utterance.voice = undefined doesn't throw — it just leaves the browser to fall back to its own default voice. So the naive version doesn't crash. It just quietly ignores your voice preference on the render that matters most, or in some cases the engine responds slower than the calling code expects and nothing audible happens at all before the rest of your script moves on.

Once the list finishes loading — which is fast, usually well under a second — every subsequent call to getVoices() on that page returns the full array, because the browser caches it for the session. That's the trap: you develop by refreshing the same tab fifty times an hour, so you basically never see the empty array. Real visitors see it exactly once, on the one load that counts.

The fix: wait for the event that says the list is actually ready

The Web Speech API ships the exact signal you need: speechSynthesis fires a voiceschanged event the moment its voice list is populated. Wrap it in a promise once, and every call site just awaits a guaranteed-full list instead of gambling on timing:

function getVoicesWhenReady() {
  return new Promise((resolve) => {
    const existing = speechSynthesis.getVoices();
    if (existing.length > 0) {
      resolve(existing);
      return;
    }
    speechSynthesis.onvoiceschanged = () => {
      resolve(speechSynthesis.getVoices());
    };
  });
}

async function speak(text) {
  const voices = await getVoicesWhenReady();
  const utterance = new SpeechSynthesisUtterance(text);
  utterance.voice = voices.find((v) => v.name.includes("Google US English")) || voices[0];
  speechSynthesis.speak(utterance);
}
Enter fullscreen mode Exit fullscreen mode

The existing.length > 0 check matters as much as the event listener — on a warm page (or a browser that populated the list before your script even ran), voiceschanged may never fire again, and a version that only listens for the event would hang forever waiting for something that already happened. Check first, listen second.

One more wrinkle worth knowing: not every browser has historically fired voiceschanged at all — WebKit-based browsers have shipped inconsistent behavior here for years. If you need to support every browser defensively, pair the event listener with a short polling fallback (check getVoices().length every 100ms for a second or two, then give up and use whatever default voice is available) rather than trusting the event alone.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

What this buys you before you reach for a paid API

None of this needed a cloud text-to-speech service, an API key, or a per-character bill. speechSynthesis is a full TTS engine every major browser ships for free — read-aloud accessibility features, language-learning drills, a screen-reader-adjacent feature for a reading app, voice notes on a form. For a lot of products, the built-in engine is genuinely enough, and reaching straight for a paid cloud API is optional infrastructure you don't need on day one.

It has real limits worth knowing before you commit to it: the voice roster is whatever the visitor's OS and browser ship, not a roster you control, so the same text sounds different on every machine. There's no SSML-level prosody control, just rate, pitch, and volume on the utterance. And it's synchronous with the tab in a loose sense — navigating away or closing the tab can cut speech off mid-sentence. None of that is a reason to skip it; it's the reason to reach for a paid engine specifically when you need voice consistency or SSML, not by default.

🧠 Test yourself

Think it clicked? Take the 8-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

That support ticket wasn't a flaky bug or a browser quirk report you could shrug off — it was a promise-shaped API being called like a synchronous one, on the exact page load where the difference actually shows up. Every warm reload during development had been hiding it from you.

Go check any speechSynthesis.getVoices() call in your own codebase. Is it guarded behind voiceschanged, or is it trusting that the list is already there? What's the weirdest "works on my machine, dies for one real user" bug you've chased down to a caching difference like this one? I'll go first in the comments.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Top comments (0)