Speech, Transcription and Voice Interfaces on the Web
A practitioner’s guide to the web speech api, transcription AI and voice interfaces: on-device recognition, MediaRecorder, costs, TTS bugs and accessibility.
A client’s field service app had a simple requirement: engineers wearing gloves, in a plant room, needed to log a fault without typing. We shipped the first version on the Web Speech API in two days. It worked beautifully on the Android tablets they’d bought and failed completely on the two iPhones in the pilot group, because Safari’s recognition needs a network round trip and that plant room had no signal.
That’s the whole subject in one story. Voice on the web is not one capability. It’s a pile of partially overlapping browser APIs, cloud services and hardware constraints, and the gap between the demo and the deployment is where everyone gets hurt. The web speech api is free, built in, and limited in ways that are not documented where you’d look for them. Hosted transcription is accurate, costs money per minute, and introduces a latency budget you have to design around.
Here’s what we actually use in 2026, where each option breaks, and the numbers that matter.
- Speech recognition in the browser is still Chromium and Safari only. Firefox has never shipped
SpeechRecognitionon by default, so any voice feature needs a typed fallback path, not a “your browser isn’t supported” dead end. - Chrome 138 added on-device recognition via
SpeechRecognition.available(),install()andrecognition.processLocally. It removes the cloud round trip and the privacy objection, at the cost of a model download measured in tens of megabytes. - Hosted transcription (Whisper class models) costs around $0.36 per audio hour and returns a 60 second clip in roughly 2 to 5 seconds. That’s fine for dictation and recordings, far too slow for conversational turn taking.
- Never ship a provider API key to the browser. Mint short lived tokens from your own endpoint, rate limited per session, with a hard monthly spend cap on the provider side.
- Pipe interim transcription results into an
aria-liveregion and you’ll flood screen readers with partial words. Announce final results only, and leave the interim text out of the live region entirely.
Voice is three problems, not one
Before picking an API, decide which of these you’re building. The right answer differs for each.
Dictation means turning speech into text that a human will read and edit. Accuracy matters more than latency. A two second delay is invisible if the result is correct.
Commands mean mapping speech to a small finite set of actions: “next”, “search for blue widgets”, “mark complete”. Vocabulary is tiny, latency must be under about 500 ms to feel responsive, and you can tolerate mediocre transcription because you’re fuzzy matching against known intents anyway.
Conversation means a full duplex voice interface where the user talks, something answers, and the user interrupts. This is an order of magnitude harder and an order of magnitude more expensive. You need voice activity detection, turn detection, barge-in handling and streaming synthesis. Most marketing sites that ask for this want commands.
We’ve been asked for conversation and delivered commands often enough that it’s now a standard question in scoping. If the answer involves a fixed set of outcomes, build the fixed set.

What the Web Speech API actually gives you
Two halves, wildly different maturity. SpeechSynthesis (text to speech) is supported everywhere and has been for a decade. SpeechRecognition is still prefixed as webkitSpeechRecognition in Chromium, available in Safari since 14.1, and absent from Firefox unless the user flips media.webspeech.recognition.enable, which nobody does.
In cloud mode, Chrome ships your audio to Google’s servers and Safari ships it to Apple’s. You get no say in the model, no vocabulary hints that reliably work, and no guarantee the service stays free. What you do get is interim results in roughly 150 to 300 ms, which no hosted REST transcription endpoint will match.
The 2025 addition worth your attention is local processing:
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) { showTypedFallback(); return; }
const rec = new SR();
rec.lang = 'en-GB';
rec.continuous = true;
rec.interimResults = true;
// Chrome 138+. Feature-detect: the static methods changed name during
// the origin trial, so never assume they exist.
if (typeof SR.available === 'function') {
const state = await SR.available({ langs: ['en-GB'], processLocally: true });
if (state === 'downloadable') {
await SR.install({ langs: ['en-GB'], processLocally: true }); // tens of MB
}
rec.processLocally = state !== 'unavailable';
}
let finalText = '';
rec.onresult = (e) => {
let interim = '';
for (let i = e.resultIndex; i < e.results.length; i++) {
const r = e.results[i];
if (r.isFinal) finalText += r[0].transcript;
else interim += r[0].transcript;
}
renderInterim(interim); // visual only
renderFinal(finalText); // this one is announced
};
rec.onerror = (e) => {
// 'not-allowed' and 'service-not-allowed' are terminal. Do not retry.
if (e.error === 'not-allowed') stopForGood();
if (e.error === 'no-speech') hintUser('We did not hear anything.');
};
Two traps. First, continuous = true is a lie in Chrome: recognition still ends after a stretch of silence, so people restart it inside onend and ship an infinite retry loop that fires the moment someone denies mic permission. Guard it with an explicit user-stopped flag and a restart counter. Second, on iOS the recognition session must begin inside a user gesture, and backgrounding the tab kills it silently.
When to send audio to a transcription AI instead
Use a hosted model when accuracy, punctuation, speaker labels, timestamps or non-English coverage matter. The browser APIs give you a bare string. A transcription ai endpoint gives you word level timings, diarisation, confidence scores and a model you can pin so your output doesn’t change under you next Tuesday.
Rough costs in 2026: Whisper class hosted transcription sits around $0.006 per minute, so $0.36 an audio hour. Streaming providers are broadly comparable per minute. Realtime speech-to-speech models cost substantially more because you’re paying for audio tokens in both directions, which is exactly why “just bolt a voice bot on the homepage” proposals tend to die at the spreadsheet stage.
Self hosting is viable if you already run a GPU. faster-whisper (CTranslate2) with large-v3 on a modest card transcribes a minute of audio in a handful of seconds; whisper.cpp with a quantised small model runs usably on CPU for short clips. For a meeting recorder, that economics works. For a 50 request per day contact form, pay per minute and move on.
Where hosted transcription loses is interactivity. The round trip is upload plus inference plus response. Even with streaming you’re typically 300 to 800 ms behind the speaker’s last word, before you’ve done anything with the text.
Capturing audio without destroying quality or the battery
For record-then-send, MediaRecorder is enough and you should let the browser pick a codec it likes:
const stream = await navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, echoCancellation: true,
noiseSuppression: true, autoGainControl: true }
});
const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/mp4'; // Safari
const recorder = new MediaRecorder(stream, { mimeType: mime, audioBitsPerSecond: 32000 });
const chunks = [];
recorder.ondataavailable = (e) => e.data.size && chunks.push(e.data);
recorder.start(250); // emit every 250 ms so a crash does not lose everything
Opus mono at 32 kbps is plenty for speech. Every serious model downsamples to 16 kHz mono internally, so sending 128 kbps stereo just buys you upload time. Remember to call stream.getTracks().forEach(t => t.stop()) when you’re done, or the browser’s recording indicator stays lit and users assume you’re spying on them.
For streaming you need raw PCM, which means an AudioWorklet that downsamples to 16 kHz and posts Int16 buffers to the main thread. Do not use ScriptProcessorNode. It’s deprecated and runs on the main thread, so it stutters the moment you render anything.
Transport for partial transcripts comes down to two options: a WebSocket if audio flows both ways, Server-Sent Events if the audio goes up over HTTP and only text comes back. The trade-offs are laid out in our piece on picking between SSE, WebSockets and webhooks.
Keys, tokens and the bill you did not expect
A browser connecting directly to a speech provider needs credentials, and a credential in client JavaScript is a public credential. Every provider worth using supports ephemeral tokens: your server exchanges the real key for one that lasts 60 seconds and is scoped to a single session. Issue it from your own endpoint, check the user’s session first, rate limit per IP and per account, and set a hard spend cap in the provider dashboard. We’ve seen a scraped demo key burn through a month’s budget in under four hours.
Speaking back: synthesis and its long standing bugs
speechSynthesis is free and instant, and it sounds like 2014. Neural TTS from a hosted provider sounds human and costs per character. Pick based on whether the voice is part of the product or just a convenience.
If you use the built in one, two bugs will bite you. Voices load asynchronously, so getVoices() returns an empty array on first call. And Chrome cuts off utterances after roughly 15 seconds unless you keep the queue awake:
function voicesReady() {
return new Promise((resolve) => {
const v = speechSynthesis.getVoices();
if (v.length) return resolve(v);
speechSynthesis.addEventListener('voiceschanged',
() => resolve(speechSynthesis.getVoices()), { once: true });
});
}
function speak(text, voice) {
const u = new SpeechSynthesisUtterance(text);
u.voice = voice; u.rate = 1.0;
let keepAlive;
// Chrome drops long utterances; a pause/resume pair resets its timer.
u.onstart = () => { keepAlive = setInterval(() => {
speechSynthesis.pause(); speechSynthesis.resume();
}, 10000); };
u.onend = u.onerror = () => clearInterval(keepAlive);
speechSynthesis.cancel(); // never queue on top of a previous answer
speechSynthesis.speak(u);
}
On iOS the first speak() call must happen inside a user gesture, exactly like audio playback. Plan for an explicit “listen” button rather than auto-speaking on page load, which you shouldn’t do anyway.
Designing a voice interface people use twice
Novelty gets the first tap. Reliability gets the second. The things that actually determine whether a voice interface survives contact with users are unglamorous.
- Push to talk beats always listening. Hold-to-speak removes endpointing entirely, removes the privacy conversation, and removes the false triggers that make people disable the feature. Use continuous listening only when hands are genuinely occupied.
- Show state, loudly. Idle, listening, processing, speaking, error. A pulsing mic with a live input level meter tells the user the browser actually got the audio. Half of all “it doesn’t work” reports are a muted hardware switch or the wrong default input device.
- Always make the transcript editable. Speech recognition gets proper nouns, postcodes and email addresses wrong, and users accept that if they can fix it before submitting. Dictating into a text field that posts to a form endpoint such as WebForms gives you voice capture on a static site with no backend at all.
- Design the failure states first. Permission denied, no microphone, offline, no speech detected, unsupported browser. Each needs its own copy and its own recovery path, which is the same discipline we argue for in designing 404s, 500s and empty states.
- Cap the session. An open microphone with a streaming bill attached needs a hard timeout, both for cost and for the user who walked away with the tab open.
If the voice feature is backed by a model that interprets what was said, the shipping discipline from prototype to production for AI features applies without modification: confidence thresholds, a visible way to correct the system, and logging you can actually audit.
Accessibility and consent, which are not the same thing
Voice input is not an accessibility feature by default. Users with motor impairments often already run Dragon or Voice Control at the OS level, and a page that grabs the microphone can fight with it. Users with speech differences may find recognition accuracy unusable. Treat voice as an additional input path, never the only one.
For transcript output, the markup matters more than the model:
<div class="transcript">
<p id="interim" class="text-muted" aria-hidden="true"></p>
<div role="log" aria-live="polite" aria-atomic="false" id="final"></div>
</div>
<button type="button" aria-pressed="false" id="mic">
<span class="visually-hidden">Start dictation</span>
</button>
Interim results are hidden from assistive tech because they change several times a second and would produce a stream of half words. Only finalised text lands in the live region. The mic button is a toggle with aria-pressed, not an icon with a title attribute. If any of that feels unfamiliar, our guide to form accessibility, labels, errors and focus management covers the same ground for the fields the transcript ends up in.
On consent: cloud recognition means audio leaves the device, and in Chrome that means Google receives it. Say so, in plain language, next to the button. If you’re recording conversations involving more than one person, two-party consent rules in several US states and biometric laws covering voiceprints may apply, and that’s a question for a lawyer rather than a blog post. The safe default is to process locally where the browser supports it, retain nothing you don’t need, and delete raw audio once the transcript exists.
Frequently Asked Questions
Does the Web Speech API work offline?
Only with on-device recognition in Chromium 138 and later, after the language model has been downloaded, and only when you set processLocally. Cloud mode in Chrome and Safari requires a network connection and will fire an error with e.error === 'network' when there isn’t one. Always feature-detect and handle that error rather than assuming availability.
Why does recognition stop on its own even with continuous set to true?
Chrome ends a session after a period of silence regardless of the continuous flag, and iOS ends it when the tab loses focus. Restart inside the onend handler, but only when the user has not explicitly stopped, and stop retrying after a few consecutive failures so a denied permission does not create an infinite loop.
Whisper or a streaming provider for live captions?
For live captions, use a purpose built streaming endpoint. Whisper processes audio in roughly 30 second windows and is not designed for incremental output, so homegrown chunking produces duplicated or truncated words at the boundaries. Keep batch Whisper for recordings, uploads and anything where a few seconds of delay is irrelevant.
How much audio can I send from the browser before cost becomes a problem?
At about $0.36 per audio hour for batch transcription, a hundred users dictating two minutes a day costs roughly $1.20 per day. Realtime speech-to-speech is far more expensive per minute. The real risk is not normal usage but an abused public endpoint, so rate limit per session and set a provider level spend cap before launch.
Can I add voice search to an existing HTML template without a backend?
Yes, if you target Chromium and Safari and fall back to the normal search field elsewhere. Recognition runs entirely in the browser, so you only need a mic button, a toggle handler and code that writes the final transcript into the existing input before submitting. No server component is involved unless you switch to a hosted transcription model.
Where to start
Pick the cheapest thing that solves your actual problem. Dictation into a form: the built in recognition API plus an editable text field will take an afternoon and cost nothing. Accurate transcription of recorded audio: upload to a hosted model and show a progress state, because people forgive waiting when they can see it happening. Conversation: budget properly for turn detection, barge-in and the bill, then build a push-to-talk version first and see whether anyone uses it.
Whatever you build, ship the typed path alongside it. Microphones get muted, rooms get noisy, permissions get denied, and the fallback is the only part of the feature that works one hundred percent of the time.


