[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-speech-synthesis-api-browser-tts":44,"search-suggestions":60,"quiz-article-speech-synthesis-api-browser-tts":105},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"id":45,"slug":46,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a085d3-d959-74da-a3d1-c33ba68d62db","speech-synthesis-api-browser-tts","PRACTICE_QUIZ","The browser's free text-to-speech engine","Check what actually stuck about the Web Speech API's speechSynthesis interface — the voice-loading race, the fix, and what it can't do.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},8,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,72,76,80,84,88,92,95,98,102],{"slug":62,"name":63,"articles":64},"webdev","Webdev",95,{"slug":66,"name":67,"articles":68},"javascript","Javascript",80,{"slug":70,"name":71,"articles":53},"frontend","Frontend",{"slug":73,"name":74,"articles":75},"css","Css",32,{"slug":77,"name":78,"articles":79},"tutorial","Tutorial",26,{"slug":81,"name":82,"articles":83},"typescript","Typescript",14,{"slug":85,"name":86,"articles":87},"performance","Performance",12,{"slug":89,"name":90,"articles":91},"react","React",11,{"slug":93,"name":94,"articles":51},"browser","Browser",{"slug":96,"name":97,"articles":51},"node","Node",{"slug":99,"name":100,"articles":101},"grammar","Grammar",6,{"slug":103,"name":104,"articles":101},"programming","Programming",{"id":106,"slug":46,"title":107,"subtitle":52,"excerpt":108,"coverUrl":109,"locale":13,"readingMinutes":110,"publishedAt":111,"viewCount":112,"likeCount":19,"commentCount":19,"author":113,"vertical":118,"topic":119,"tags":121,"_count":128,"playground":130,"body":132,"bodyMd":253,"seo":254,"translationGroupId":257,"series":52,"podcastUrl":52,"verticalId":5,"thread":258,"assessments":260,"translations":263,"quiz":265},"01a085d3-d892-7065-956a-2f646cc7107e","The 'Read Aloud' Button That Only Works on the Second Click","A text-to-speech button that works perfectly every time you test it — and silently fails or picks the wrong voice for a real visitor's first click. The bug is a timing race baked into the Web Speech API that most code never accounts for.","\u002Fmedia\u002Fcovers\u002Fspeech-synthesis-api-browser-tts.png",5,"2026-09-14T12:18:29.717Z",16,{"id":114,"name":115,"username":116,"avatarUrl":52,"headline":117},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":66,"name":120},"JavaScript",[122,123,124,127],{"slug":66,"name":67,"color":52},{"slug":62,"name":63,"color":52},{"slug":125,"name":126,"color":52},"accessibility","Accessibility",{"slug":77,"name":78,"color":52},{"assessments":129},1,{"slug":46,"title":131},"speechSynthesis voice race — interactive playground",{"blocks":133,"version":129},[134,138,141,145,148,154,157,160,163,167,170,173,176,179,182,186,189,192,195,198,201,204,207,210,213,216,219,222,225,228,231,234,237,240,243,246],{"id":135,"html":136,"type":137},"b1","\u003Cp>A support ticket lands: &quot;The &#39;listen to this article&#39; button did nothing. I clicked it, waited, clicked it again — nothing. No error in the console either.&quot; You open the page to check. The button reads the article back to you immediately, in a clean voice, first click, every time.\u003C\u002Fp>","paragraph",{"id":139,"html":140,"type":137},"b2","\u003Cp>You close the ticket as &quot;unable to reproduce&quot; and move on. It reopens two days later from a different user, same symptom. That&#39;s when you notice the thing you&#39;d missed: you have never once tested this feature on a page you hadn&#39;t already reloaded a dozen times that session. Every one of your test runs had a warm browser. Real visitors don&#39;t.\u003C\u002Fp>",{"id":142,"html":143,"text":143,"type":144,"level":31},"b3","The four lines everyone writes first","heading",{"id":146,"html":147,"type":137},"b4","\u003Cp>Here&#39;s the version that looks completely reasonable, and is what most tutorials show:\u003C\u002Fp>",{"id":149,"code":150,"type":151,"language":152,"highlight":153},"b5","function speak(text) {\n  const voices = speechSynthesis.getVoices();\n  const utterance = new SpeechSynthesisUtterance(text);\n  utterance.voice = voices.find((v) => v.name.includes(\"Google US English\")) || voices[0];\n  speechSynthesis.speak(utterance);\n}\n\ndocument.querySelector(\"#read-aloud\").addEventListener(\"click\", () => speak(articleText));","code","js",[],{"id":155,"html":156,"type":137},"b6","\u003Cp>\u003Ccode>speechSynthesis\u003C\u002Fcode> is a real object, sitting on \u003Ccode>window\u003C\u002Fcode> in every major browser, with a full text-to-speech engine behind it — no API key, no network request, no cost. \u003Ccode>getVoices()\u003C\u002Fcode> returns an array of installed voices, you pick one, you call \u003Ccode>speak()\u003C\u002Fcode>. It reads like it should just work.\u003C\u002Fp>",{"id":158,"html":159,"type":137},"b7","\u003Cp>\u003Cstrong>Guess before you scroll:\u003C\u002Fstrong> on the very first click, after a genuinely fresh page load, how many voices does \u003Ccode>voices\u003C\u002Fcode> actually contain?\u003C\u002Fp>",{"id":161,"html":162,"type":137},"b8","\u003Cp>Zero. Or close to it. Not because the browser has no voices installed — because it hasn&#39;t finished asking its speech engine for the list yet.\u003C\u002Fp>",{"id":164,"html":165,"text":166,"type":144,"level":31},"b9","Why &quot;just call getVoices()&quot; is a race you usually win by accident","Why \"just call getVoices()\" is a race you usually win by accident",{"id":168,"html":169,"type":137},"b10","\u003Cp>\u003Ccode>speechSynthesis.getVoices()\u003C\u002Fcode> doesn&#39;t fetch anything when you call it — it just returns whatever the browser has populated \u003Cstrong>so far\u003C\u002Fstrong>. 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&#39;t finished by the time your click handler runs a few hundred milliseconds after the page painted.\u003C\u002Fp>",{"id":171,"html":172,"type":137},"b11","\u003Cp>The first time you call it, you can easily get \u003Ccode>[]\u003C\u002Fcode>. \u003Ccode>voices.find(...)\u003C\u002Fcode> on an empty array returns \u003Ccode>undefined\u003C\u002Fcode>, \u003Ccode>voices[0]\u003C\u002Fcode> on an empty array is also \u003Ccode>undefined\u003C\u002Fcode>, and \u003Ccode>utterance.voice = undefined\u003C\u002Fcode> doesn&#39;t throw — it just leaves the browser to fall back to its own default voice. So the naive version doesn&#39;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.\u003C\u002Fp>",{"id":174,"html":175,"type":137},"b12","\u003Cp>Once the list finishes loading — which is fast, usually well under a second — every \u003Cem>subsequent\u003C\u002Fem> call to \u003Ccode>getVoices()\u003C\u002Fcode> on that page returns the full array, because the browser caches it for the session. That&#39;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.\u003C\u002Fp>",{"id":177,"html":178,"text":178,"type":144,"level":31},"b13","The fix: wait for the event that says the list is actually ready",{"id":180,"html":181,"type":137},"b14","\u003Cp>The Web Speech API ships the exact signal you need: \u003Ccode>speechSynthesis\u003C\u002Fcode> fires a \u003Ccode>voiceschanged\u003C\u002Fcode> 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:\u003C\u002Fp>",{"id":183,"code":184,"type":151,"language":152,"highlight":185},"b15","function getVoicesWhenReady() {\n  return new Promise((resolve) => {\n    const existing = speechSynthesis.getVoices();\n    if (existing.length > 0) {\n      resolve(existing);\n      return;\n    }\n    speechSynthesis.onvoiceschanged = () => {\n      resolve(speechSynthesis.getVoices());\n    };\n  });\n}\n\nasync function speak(text) {\n  const voices = await getVoicesWhenReady();\n  const utterance = new SpeechSynthesisUtterance(text);\n  utterance.voice = voices.find((v) => v.name.includes(\"Google US English\")) || voices[0];\n  speechSynthesis.speak(utterance);\n}",[],{"id":187,"html":188,"type":137},"b16","\u003Cp>The \u003Ccode>existing.length &gt; 0\u003C\u002Fcode> check matters as much as the event listener — on a warm page (or a browser that populated the list before your script even ran), \u003Ccode>voiceschanged\u003C\u002Fcode> 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.\u003C\u002Fp>",{"id":190,"html":191,"type":137},"b17","\u003Cp>One more wrinkle worth knowing: not every browser has historically fired \u003Ccode>voiceschanged\u003C\u002Fcode> 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 \u003Ccode>getVoices().length\u003C\u002Fcode> every 100ms for a second or two, then give up and use whatever default voice is available) rather than trusting the event alone.\u003C\u002Fp>",{"id":193,"html":194,"type":137},"b18","\u003C!-- playground:start -->",{"id":196,"html":197,"text":197,"type":144,"level":31},"b19","🎮 Try it yourself",{"id":199,"html":200,"type":137},"b20","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fspeech-synthesis-api-browser-tts\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":202,"html":203,"type":137},"b21","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":205,"html":206,"type":137},"b22","\u003C!-- playground:end -->",{"id":208,"html":209,"text":209,"type":144,"level":31},"b23","What this buys you before you reach for a paid API",{"id":211,"html":212,"type":137},"b24","\u003Cp>None of this needed a cloud text-to-speech service, an API key, or a per-character bill. \u003Ccode>speechSynthesis\u003C\u002Fcode> 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&#39;t need on day one.\u003C\u002Fp>",{"id":214,"html":215,"type":137},"b25","\u003Cp>It has real limits worth knowing before you commit to it: the voice roster is whatever the visitor&#39;s OS and browser ship, not a roster you control, so the same text sounds different on every machine. There&#39;s no SSML-level prosody control, just \u003Ccode>rate\u003C\u002Fcode>, \u003Ccode>pitch\u003C\u002Fcode>, and \u003Ccode>volume\u003C\u002Fcode> on the utterance. And it&#39;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&#39;s the reason to reach for a paid engine specifically when you need voice consistency or SSML, not by default.\u003C\u002Fp>",{"id":217,"html":218,"type":137},"b26","\u003C!-- quiz:start -->",{"id":220,"html":221,"text":221,"type":144,"level":31},"b27","🧠 Test yourself",{"id":223,"html":224,"type":137},"b28","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fspeech-synthesis-api-browser-tts\u002Fquiz\">Take the 8-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":226,"html":227,"type":137},"b29","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":229,"html":230,"type":137},"b30","\u003C!-- quiz:end -->",{"id":232,"html":233,"type":137},"b31","\u003Cp>That support ticket wasn&#39;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.\u003C\u002Fp>",{"id":235,"html":236,"type":137},"b32","\u003Cp>Go check any \u003Ccode>speechSynthesis.getVoices()\u003C\u002Fcode> call in your own codebase. Is it guarded behind \u003Ccode>voiceschanged\u003C\u002Fcode>, or is it trusting that the list is already there? What&#39;s the weirdest &quot;works on my machine, dies for one real user&quot; bug you&#39;ve chased down to a caching difference like this one? I&#39;ll go first in the comments.\u003C\u002Fp>",{"id":238,"type":239},"b33","divider",{"id":241,"html":242,"type":137},"b34","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":244,"html":245,"type":137},"b35","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":247,"type":248,"items":249,"ordered":18},"b36","list",[250,251,252],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","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.\n\nYou 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.\n\n## The four lines everyone writes first\n\nHere's the version that looks completely reasonable, and is what most tutorials show:\n\n```js\nfunction speak(text) {\n  const voices = speechSynthesis.getVoices();\n  const utterance = new SpeechSynthesisUtterance(text);\n  utterance.voice = voices.find((v) => v.name.includes(\"Google US English\")) || voices[0];\n  speechSynthesis.speak(utterance);\n}\n\ndocument.querySelector(\"#read-aloud\").addEventListener(\"click\", () => speak(articleText));\n```\n\n`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.\n\n**Guess before you scroll:** on the very first click, after a genuinely fresh page load, how many voices does `voices` actually contain?\n\nZero. 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.\n\n## Why \"just call getVoices()\" is a race you usually win by accident\n\n`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.\n\nThe 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.\n\nOnce 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.\n\n## The fix: wait for the event that says the list is actually ready\n\nThe 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:\n\n```js\nfunction getVoicesWhenReady() {\n  return new Promise((resolve) => {\n    const existing = speechSynthesis.getVoices();\n    if (existing.length > 0) {\n      resolve(existing);\n      return;\n    }\n    speechSynthesis.onvoiceschanged = () => {\n      resolve(speechSynthesis.getVoices());\n    };\n  });\n}\n\nasync function speak(text) {\n  const voices = await getVoicesWhenReady();\n  const utterance = new SpeechSynthesisUtterance(text);\n  utterance.voice = voices.find((v) => v.name.includes(\"Google US English\")) || voices[0];\n  speechSynthesis.speak(utterance);\n}\n```\n\nThe `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.\n\nOne 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.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fspeech-synthesis-api-browser-tts\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n## What this buys you before you reach for a paid API\n\nNone 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.\n\nIt 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.\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 8-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fspeech-synthesis-api-browser-tts\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\nThat 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.\n\nGo 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.\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":107,"canonical":255,"description":256},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fspeech-synthesis-api-browser-tts","A text-to-speech button that works perfectly every time you test it — and silently fails or picks the wrong voice for a real visitor's first click. The bug is a timing race baked i","01a085d3-d892-7065-956a-3318c6228070",{"id":259,"locked":18},"01a085d3-d93b-71ee-a149-8ebc6c2339aa",[261],{"id":45,"slug":46,"title":48,"_count":262},{"questions":51},[264],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":266,"questionCount":51},{"questions":51}]