Browser integration and best practices
On this page
Updated November 3, 2026, based on the xAI Text to Speech page: server-side validation uses the current limit of 60,000 characters per request. Source: Text to Speech.
Deploy TTS in a web app
Integrating TTS in a browser comes with specific challenges: codec support, memory management, Safari quirks, and performance. This final lesson gives you what you need for a robust production integration.
Browser codec support
Not every browser supports every audio format. Choose the right codec for your audience:
| Codec | Chrome | Firefox | Safari | Edge | Recommended |
|---|---|---|---|---|---|
| MP3 | Yes | Yes | Yes | Yes | Yes |
| WAV | Yes | Yes | Yes | Yes | Yes |
| Raw PCM | No | No | No | No | No |
| mulaw/alaw | No | No | No | No | No |
The simple rule: use MP3 or WAV in the browser. PCM, mulaw, and alaw are meant for server apps and telephony, not web playback.
// Request MP3 from the API (through your backend)
const response = await fetch("/api/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: "Hello from the browser.",
voice_id: "eve",
language: "en"
// No need to set output_format: MP3 is the default
})
});
The Safari AudioContext problem
Safari imposes a security restriction: an AudioContext can only be created inside a user event handler (click, keypress, tap). If you try to create an AudioContext when the page loads, Safari silently blocks it.
The wrong approach
// Does NOT work in Safari
const audioContext = new AudioContext(); // Blocked!
async function playTTS() {
const audio = await fetchTTS("Hello");
// audioContext is suspended in Safari...
}
The right approach
let audioContext = null;
// Create the AudioContext on the user's first click
document.getElementById("play-btn").addEventListener("click", async () => {
if (!audioContext) {
audioContext = new AudioContext();
}
// Resume if suspended
if (audioContext.state === "suspended") {
await audioContext.resume();
}
const response = await fetch("/api/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: "Hello from Safari.",
voice_id: "eve",
language: "en"
})
});
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContext.destination);
source.start(0);
});
This approach works in every browser, not just Safari. It’s a good universal practice.
Blob URLs and memory leaks
When you create Blob URLs to play audio, each URL uses memory until you explicitly release it. Without cleanup, your app will leak memory.
The problem
// Each call creates a Blob URL that is never released
async function playTTS(text) {
const response = await fetch("/api/tts", { ... });
const blob = await response.blob();
const url = URL.createObjectURL(blob); // Memory leak!
const audio = new Audio(url);
audio.play();
}
The solution
async function playTTS(text) {
const response = await fetch("/api/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, voice_id: "eve", language: "en" })
});
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
// Free the memory when playback ends
audio.addEventListener("ended", () => {
URL.revokeObjectURL(url);
});
// Also free it on error
audio.addEventListener("error", () => {
URL.revokeObjectURL(url);
});
audio.play();
}
For apps that generate a lot of audio, keep a list of active URLs and clean them up periodically:
class AudioManager {
constructor() {
this.activeUrls = new Set();
}
async play(text) {
const response = await fetch("/api/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, voice_id: "eve", language: "en" })
});
const blob = await response.blob();
const url = URL.createObjectURL(blob);
this.activeUrls.add(url);
const audio = new Audio(url);
audio.addEventListener("ended", () => this.release(url));
audio.addEventListener("error", () => this.release(url));
audio.play();
return audio;
}
release(url) {
URL.revokeObjectURL(url);
this.activeUrls.delete(url);
}
cleanup() {
// Release every active URL
for (const url of this.activeUrls) {
URL.revokeObjectURL(url);
}
this.activeUrls.clear();
}
}
Production best practices
Backend rate limiting
Protect your backend from abuse by limiting requests per user:
const rateLimit = require("express-rate-limit");
const ttsLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10, // 10 requests per minute per IP
message: { error: "Too many TTS requests" }
});
app.post("/api/tts", ttsLimiter, async (req, res) => {
// ... proxy to the xAI API
});
Text validation
Validate and sanitize the text before sending it to the API:
function validateTTSInput(text) {
if (!text || typeof text !== "string") {
throw new Error("Text is required");
}
if (text.length > 60000) {
throw new Error("Text too long (60,000 characters max)");
}
// Strip control characters
return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "");
}
Audio caching
For repeated texts (greetings, standard instructions), cache the generated audio:
const audioCache = new Map();
async function getTTS(text, voice, language) {
const cacheKey = `${text}:${voice}:${language}`;
if (audioCache.has(cacheKey)) {
return audioCache.get(cacheKey);
}
const audio = await fetchTTSFromAPI(text, voice, language);
audioCache.set(cacheKey, audio);
return audio;
}
Pre-generation
For content you know in advance (e-learning, IVR), pre-generate the audio files at build time rather than on the fly.
Key takeaways
- Use MP3 or WAV in the browser, never PCM/mulaw/alaw
- In Safari, create the AudioContext inside a user event handler
- Always release Blob URLs with
URL.revokeObjectURL()after use - Add rate limiting to your backend proxy
- Validate and sanitize text before sending it to the API
- Cache the audio for repeated texts to save API calls
- Pre-generate audio for content you know in advance
Check your understanding
xAI speech synthesis: from the first call to the streaming pipeline.
1. How does the basic TTS call work?
Answer: A POST to /v1/tts with the text, the chosen voice (which you can list through the API), and the parameters. The response is the audio, billed by the volume synthesized.
2. What do expressive tags let you do?
Answer: Control speech from within the text: inline and wrapping tags for intonation, pauses, and emphasis, which you can combine for natural, directed readings.
3. When should you switch to TTS WebSocket?
Answer: For real time: the text goes out as a stream and the audio comes back continuously. It’s the foundation of LLM → TTS pipelines, where the voice starts before generation is finished.
4. How do you secure a browser integration?
Answer: Never put the API key in the client: the browser goes through your server or uses short-lived credentials, and you handle HTTP errors properly.
5. What does the complete production pipeline look like?
Answer: A streaming LLM → text segments → TTS WebSocket → audio playback. Every link streams, so perceived latency collapses, and the best practices from this course close the loop.
Tags for expressiveness, WebSocket for real time, your server for secrets: your apps are ready to speak.