Running text-to-speech entirely in the browser sounds simple: download an ONNX model, create an inference session, and synthesize audio without sending user text or media to a server.
In production, it is much harder.
While building multilingual voice generation for Timeline Studio, we repeatedly saw the same failure: the model-loading UI stopped at 86%, the Generate button stayed busy, and switching from Chinese to English, German, Korean, Thai, or Japanese made the issue more likely.
The most useful console message was:
Unable to cache file QuotaExceededError: Quota exceeded.
QuotaExceededError:
The operation failed because it would cause the application
to exceed its storage quota.
The progress bar was only the symptom. The real problem was the interaction between large model files, multiple TTS runtimes, browser storage quotas, service-worker caching, WebGPU initialization, and region-specific model mirrors.
This post explains the architecture changes that made the multilingual pipeline reliable.
- Project: https://github.com/MartinDelophy/ai-video-editor
- Live demo: https://video-editor.ai-creator.top/
The browser was doing more than the progress bar showed
A browser TTS model normally goes through several stages:
- Download configuration, phonemizer, tokenizer, and vocabulary files.
- Download one or more ONNX model files.
- Store artifacts in Cache Storage.
- Create an ONNX Runtime session.
- Compile the graph for WebGPU or initialize WASM.
- Run a warm-up inference.
- Finally synthesize the requested speech.
Our original progress calculation mainly represented network downloads. If the files finished downloading but cache insertion or session creation failed, the UI retained the last reported value — often 86%.
So the browser was not necessarily still downloading anything. It had already moved into an unrepresented stage and thrown an exception before the state machine could reach either success or a useful error state.
The fix started by treating model setup as a real multi-stage operation:
- Checking local model cache
- Downloading model artifacts
- Initializing the local inference engine
- Preparing the selected voice
- Generating speech
During initialization, the UI now says what is actually happening instead of pretending another file is still downloading.
One origin, many competing caches
Timeline Studio supports several browser-local voice runtimes because one model family is not the best choice for every language:
| Language group | Runtime | Execution path |
|---|---|---|
| Chinese | Piper | WebGPU first, WASM fallback |
| English | Kokoro Q8 | WASM |
| Selected European languages | Piper | WASM |
| Korean, Thai, Vietnamese, Russian | MMS | WASM |
| Japanese | Supertonic | WASM |
All of these runtimes operate under the same browser origin. Cache Storage, IndexedDB, and service-worker caches therefore compete for the same site quota.
After a user tried several voices, the origin could contain:
- Current model files
- Older model revisions
- Multiple quantization variants
- Duplicate files from different mirrors
- Service-worker response copies
- Runtime-specific cache entries
Each individual cache looked reasonable. Together, they could exceed the browser's storage allowance.
Separate model identity from download URL
The most important caching change was to stop treating a model URL as the model's identity.
This is fragile:
const cacheKey = modelDownloadUrl;
The same immutable artifact can be served by ModelScope in China and Hugging Face elsewhere. If the full URL becomes the cache key, identical bytes from two providers occupy two independent cache entries.
Instead, we generate a provider-independent identity:
const cacheIdentity = [
modelFamily,
immutableRevision,
language,
voice,
quantization,
].join(":");
For example:
kokoro:revision-20260804:en:female:q8
Both mirror URLs resolve to that same logical entry. The provider can change without forcing a redownload or duplicating hundreds of megabytes.
This also makes cache migration predictable: an artifact revision is explicit, and a new revision naturally receives a new identity.
Use regional mirrors without fragmenting the cache
Browser-local inference still needs a network connection the first time a voice is used.
For Chinese-language and domestic sessions, Timeline Studio tries the owned ModelScope mirror first. Other sessions prefer the owned Hugging Face repository. If the preferred source fails, the loader tries the fallback source automatically.
The simplified flow looks like this:
async function loadVoiceArtifact(artifact: VoiceArtifact) {
const cached = await readSharedVoiceCache(artifact.cacheIdentity);
if (cached) return cached;
for (const source of getPreferredSources()) {
try {
const bytes = await downloadArtifact(source, artifact);
await writeSharedVoiceCache(artifact.cacheIdentity, bytes);
return bytes;
} catch (error) {
reportSourceFailure(source, error);
}
}
throw new VoiceModelUnavailableError();
}
Every production artifact is pinned to an immutable provider revision. That prevents a remote main branch from silently changing model bytes or breaking the browser runtime.
The product also never displays a raw Failed to fetch message. Users receive a localized explanation that the model could not be downloaded and that the fallback source was attempted.
A smaller model can be the faster product decision
The English pipeline originally used a roughly 325 MB FP32 Kokoro model with a WebGPU-first path.
That configuration looked attractive in benchmarks, but it created several real-world problems:
- Long first download
- High Cache Storage pressure
- Unpredictable WebGPU graph compilation
- Driver-specific failures
- More competition with other language models
We switched the English path to a roughly 92 MB Q8 model and a stable WASM execution provider:
const session = await ort.InferenceSession.create(modelBuffer, {
executionProviders: ["wasm"],
graphOptimizationLevel: "all",
});
For an editor, voice generation is usually an occasional operation, not a continuously saturated inference workload. A smaller quantized model that reliably loads on more devices creates a better user experience than a theoretically faster GPU path that frequently fails before inference begins.
The design goal became:
- First generation succeeds
- Repeat generation reuses the cache
- Switching languages does not break existing voices
- Mid-range devices remain supported
- WebGPU has a bounded WASM fallback where appropriate
Evict stale voice models, not the entire application cache
Deleting every cache when storage is full is easy, but it punishes the user by removing the model they just downloaded.
Instead, the cache manager protects the active voice and removes stale voice artifacts first:
- Determine the cache identity required by the current voice.
- Mark that entry as protected.
- Remove obsolete model revisions.
- Evict least-recently-used inactive voice models.
- Preserve application assets and the active voice.
- Retry the cache write.
- If persistent storage still fails, allow the current inference to continue from memory when possible.
A simplified quota check is:
async function ensureVoiceStorage(activeIdentity: string) {
const estimate = await navigator.storage.estimate();
if (!estimate.quota || !estimate.usage) return;
const usageRatio = estimate.usage / estimate.quota;
if (usageRatio >= 0.8) {
await evictStaleVoiceModels({
preserve: [activeIdentity],
strategy: "least-recently-used",
});
}
}
This turns a hard quota failure into a recoverable resource-management event.
Give large model files one caching owner
Service workers are excellent for JavaScript bundles, styles, icons, and ordinary static resources. They become dangerous when they independently cache very large ONNX responses that are already managed by a model loader.
Cloning and caching the same response in both layers can silently double storage use.
We established a single-responsibility rule:
- The voice model manager owns large model artifacts.
- The service worker does not duplicate large ONNX files.
- Normal application assets remain under service-worker control.
- Every TTS runtime uses the shared voice-artifact manifest.
This made storage usage measurable and model cleanup deterministic.
Progress should be byte-weighted and stage-aware
A 5 KB configuration file and a 92 MB model should not contribute equally to progress.
The downloader now reports progress using actual bytes:
const progress = loadedBytes / totalBytes;
onProgress(Math.round(progress * 100));
When the network phase ends, the UI resets into the initialization phase rather than holding at an arbitrary download percentage.
Not every runtime exposes identical internal progress, so the application uses a shared high-level contract while allowing each adapter to provide the best signals it has. The UI remains honest even when graph compilation itself cannot provide byte-level progress.
Let the browser paint before heavy WASM work
There was another small but important issue.
React state updates are asynchronous. If we set the generation status and immediately begin synchronous or CPU-heavy WASM work, the main thread may not paint the new state. To the user, the page appears frozen before it ever shows a useful message.
We now yield one frame before starting inference:
setGenerationState({
status: "generating",
progress: 0,
});
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
await generateVoice();
This does not make inference faster, but it makes the product feel responsive because the browser can display the transition before entering the heavy task.
Hide runtime diversity behind one interface
A multilingual product should not force the editor UI to understand every model family.
Each adapter implements a common contract:
interface VoiceRuntime {
prepare(options: VoiceOptions): Promise<void>;
synthesize(text: string): Promise<AudioBuffer>;
dispose(): Promise<void>;
}
The timeline, asset library, and export pipeline work with generated audio regardless of whether the source was Piper, Kokoro, MMS, or Supertonic.
That separation also makes it possible to change quantization, execution providers, or mirror routing without rewriting product-level editing features.
What we verified
After the changes, we tested:
- First-time English model setup and repeated generation
- Repeated German generation
- Korean local inference
- Thai model download and synthesis
- Japanese Supertonic initialization
- Chinese mirror preference and provider fallback
- Automatic cleanup near the browser quota
- Cache reuse after a page refresh
Repeated generation no longer presents itself as another full model download, and quota failures no longer leave the interface permanently stuck at 86%.
Lessons for browser AI applications
The biggest lesson is that a model running once in a local prototype is not the same as a reliable browser AI feature.
A production implementation should answer all of these questions:
- Are model files pinned to immutable revisions?
- Do regional mirrors share a cache identity?
- Can the service worker duplicate large artifacts?
- What happens when persistent storage is almost full?
- Does progress represent real bytes and real stages?
- Does the UI get a chance to paint before heavy inference?
- Is there a stable fallback when WebGPU is unavailable?
- Are unused language models eventually evicted?
The original “stuck at 86%” report looked like a progress-bar bug. In reality, it exposed an architectural problem spanning storage, networking, inference, and UI scheduling.
By introducing quantized models, shared artifact identities, regional mirror fallback, quota-aware eviction, stage-aware progress, and stable WASM paths, multilingual voice generation became far more predictable across browsers and regions.
If you are building local-first AI in the browser, treat model distribution and storage as first-class infrastructure. Successful inference is only the beginning; reliable recovery across devices, networks, and storage conditions is what turns it into a product.
Open-source project: https://github.com/MartinDelophy/ai-video-editor
Try it online: https://video-editor.ai-creator.top/
Top comments (0)