The most repeated fix for this bug targets a bottleneck that cannot exist.
| The claim | What's actually true | |
|---|---|---|
| 1 | Decoding blocks the main thread | The W3C spec forbids it. Chromium, Gecko and WebKit all comply |
| 2 | So move the decode into a Worker | There is no AudioContext in a Worker. That fix isn't weak, it's unwritable |
| 3 | Then feed it the file in chunks |
decodeAudioData accepts complete files only. WebCodecs does chunk, at a price |
| 4 | The right wavesurfer.js setting, or a newer version, avoids it | No peaks means the whole file gets decoded — in either backend, in v6 and v7 alike |
| 5 |
getPeaks() is a second bottleneck, this one on the main thread |
Its step size scales with the file, so the work tracks canvas width. This is the one I got wrong myself |
What actually kills the tab is memory. 30 minutes of stereo 44.1 kHz decodes to ~635 MB of raw float32, all of it resident at once.
I went looking for the freeze everyone describes. I maintain react-modern-audio-player, and a two-year-old issue there is what sent me into other people's source — line by line, from the fetch down to the allocation. Almost none of what I found is specific to my package. Then I read the spec.
The freeze isn't allowed to happen.
Every answer I found said the same thing: decoding blocks the main thread, so move it to a Worker, or split the file into chunks. It isn't only forum folklore. The maintained troubleshooting docs for wavesurfer.js describe browser-side decoding as "slow for long files".
That framing has a problem at the root, and every fix built on top of it inherits the problem.
Misunderstanding 1: Decoding blocks the main thread
The truth: the W3C spec forbids it
BaseAudioContext.decodeAudioData() isn't left to vendor judgment:
When queuing a decoding operation to be performed on another thread, the following steps MUST happen on a thread that is not the control thread nor the rendering thread, called the decoding thread.
MUST is normative language in a W3C document, not a hint. And it splits the work at a named place:
control thread everything up to detaching the ArrayBuffer
|
v
decoding thread the decode itself <- several may run in parallel
Only the decode is pushed off. Everything before it still runs where you called it.
Do all three engines actually comply?
A spec describes intent. Engines describe reality. So I checked all three.
| Engine | What the source says |
|---|---|
| Chromium / Blink |
AsyncAudioDecoder "asynchronously decodes audio file data from a DOMArrayBuffer in a worker thread" |
| Firefox / Gecko | A thread pool with several threads, where other browsers serialize decodes onto one, per Paul Adenot of Mozilla, co-editor of the Web Audio spec |
| Safari / WebKit |
AsyncAudioDecoder.cpp spun up a dedicated Thread::create("Audio Decoder", ...); a 2023 refactor moved it onto a background RunLoop, still off the main thread |
The WebKit row is the one I'd point at. That refactor's diff carries this comment: "The ArrayBuffer must be deleted on the main thread, send it back there to be derefed." Nobody writes that line unless the code holding the buffer is running somewhere else.
It wasn't always this way. WebKit bug 61947, "AudioContext needs non-blocking call to create AudioBuffer from audio file data", is the ticket that introduced async decoding in the first place. My guess is that's where the folklore comes from, though I can't prove the lineage of a rumor.
One thing this does not claim: that your main thread sits idle while a waveform appears. Drawing happens there, and I put numbers on it further down. The claim is narrower and more useful than "nothing blocks" — the work that grows with the length of your file is not on the main thread. Nothing you do to that thread will change what a three-hour file costs.
So what kills the tab? Memory
In the allocator.
Chromium carries a commit titled "[WebAudio] Mitigate OOM crashes in decodeAudioData gracefully". It opens by calling the bug "release-blocking", then gives the mechanism: "When decoding a huge in-memory audio data, internal allocation constraints trigger a hard CHECK(bus) inside AudioBus::Create." Chrome held a release over this one. Not over jank.
Mozilla's bug 1066036 is titled "OOM causes a browser crash in decodeAudioData." The report describes a browser that can "hard-crash to desktop, instead of gracefully failing". On Stack Overflow, tabs die past 4 GB of Web Audio allocation, and each decode adds roughly 100 MB for a typical MP3 that a regular AudioContext never fully releases (only OfflineAudioContext frees it).
None of these are freezes. They're deaths.
The arithmetic: 352,800 bytes per second of audio
Web Audio keeps decoded audio as 32-bit float PCM, uncompressed, resident in memory. How well the source file compressed is irrelevant, because the compression is gone by the time the buffer exists.
44,100 samples/sec x 2 channels x 4 bytes/sample = 352,800 bytes/sec
1 minute ~21.2 MB
30 minutes ~635 MB
1 hour ~1.27 GB
3 hours ~3.8 GB
Fair warning on how I got here. No performance trace, no heap snapshot: the specification text, a library's source read line by line, and the multiplication above.
Mobile ceilings, where the numbers disagree
Apple publishes no per-tab memory limit, so every ceiling below is third-party and the sources are far apart.
| Source | Reported ceiling | What it actually is |
|---|---|---|
| Jeff Johnson, Jan 2026 | ~100 MB on iPhone SE (3rd gen), ~200 MB on 8th-gen iPad, both iOS 26.2 | One person, dated, reproducible |
| Community table | 200 to 250 MB on older iPhones, past 1 GB on the iPhone 15 generation | Compiled, and flagged unofficial by its own authors |
| Various write-ups | 1.5 GB and higher | Uncorroborated |
| Apple | Nothing published | There is no official number to cite |
That's a five to tenfold spread, so treat any single figure as folklore, including mine. Only the direction is defensible: one 30-minute stereo decode is on the order of an entire page's budget on a mid-range phone, before you count the framework, the images, or the compressed file still in memory.
Misunderstanding 2: So move the decode into a Worker
The truth: there is no AudioContext in a Worker
This is where the advice stops merely underperforming. It can't be written down.
decodeAudioData is a method on BaseAudioContext, and the two interfaces people reach for sit at opposite ends of the same attribute:
| Interface | IDL exposure |
|---|---|
BaseAudioContext, which owns decodeAudioData
|
[Exposed=Window] |
AudioDecoder, from WebCodecs |
[Exposed=(Window,DedicatedWorker), SecureContext] |
Window only. A Worker has no AudioContext to call the method on, so moving this particular call off the main thread isn't a fix that disappoints. It's a line of code that doesn't exist.
(OfflineAudioContext, the one wavesurfer reaches for, inherits from BaseAudioContext. WebIDL requires a child interface's exposure to stay inside its parent's, so it can't reach a Worker either.)
Why this advice never dies
Worth sitting with. Nobody who tried it reported back that it was slow. They couldn't get far enough to measure anything.
Misunderstanding 3: Then feed it the file in chunks
The truth: decodeAudioData takes whole files only
Also no, at least not through this API. MDN describes decodeAudioData as working "only on complete file data, not fragments of audio file data": whole file in, whole AudioBuffer out, one allocation you don't get to stage.
But the instinct behind the question is right, and this is the one place in this article where the answer is a straight yes. There is a chunked decoder in the browser. It just isn't this one.
WebCodecs is the chunked decoder, and it belongs in a Worker
WebCodecs is a queue. You push an EncodedAudioChunk into decode(), an output callback hands you an AudioData, and you release each one before the next arrives.
const decoder = new AudioDecoder({ output: onChunkDecoded });
decoder.decode(encodedAudioChunk);
function onChunkDecoded(audioData) {
collectPeaks(audioData);
audioData.close();
}
close() carries the whole idea. MDN describes it as clearing "all states and releases the reference to the media resource", so peaks come out per chunk and the chunk goes away.
Backpressure exists as well, through decodeQueueSize and the dequeue event, and AudioData is transferable between threads. This one genuinely belongs in a Worker, which is the entire difference from the previous section.
What chunking costs today
| Constraint | Detail |
|---|---|
| Safari 16.4 to 18.x | No AudioDecoder. WebCodecs shipped, but the audio half was missing |
| Safari 26, autumn 2025 | Audio arrives. WebKit's announcement: "expands support for WebCodecs API by adding AudioEncoder and AudioDecoder" |
| Chrome, Edge | Around v94, 2021 |
| Firefox | Around v130, 2024 |
| Containers | WebCodecs does not demux. Getting from a file to EncodedAudioChunks needs a separate library such as web-demuxer or mediabunny
|
| MP3 |
W3C: "Implementers of WebCodecs are not required to support the MP3 codec." Check isConfigSupported() before relying on it |
| Chunk boundaries | Arbitrary byte slices don't work. Frames have to stay intact, and one report has 16KB slicing make later decode() calls fail |
Chunked decoding bounds the decoded side, not the compressed one:
compressed bytes 16,000 B/s may still arrive in full (your demuxer's call)
decoded PCM 352,800 B/s one chunk, released before the next arrives
= 22x <- and this is the row that kills the tab
Both figures are per second of stereo audio, the compressed one at 128 kbps.
One caveat I can't size. Releasing every AudioData is necessary, and platform-specific exceptions have been reported where memory still climbs. I haven't verified those first-hand, so treat chunked decoding as something to measure on real devices rather than as a memory guarantee.
The devices with the tightest memory budgets got this API last.
So chunking isn't wrong as an idea. Today it costs a demuxer, a codec support check, frame-accurate chunking, and a Safari floor of late 2025.
Misunderstanding 4: the right wavesurfer setting, or a newer version, avoids it
The truth: no peaks, no escape — in either backend, in either version
Two escape routes get suggested, and they fail for the same reason. Take them in order.
v6: the MediaElement backend decodes anyway
Concretely, in wavesurfer.js v6.6.4, the version I read. decodeArrayBuffer() hands the whole ArrayBuffer to decodeAudioData(), and grepping the file for Worker or postMessage returns nothing.
| Where | What it does |
|---|---|
src/webaudio.js:337-358 |
decodeArrayBuffer() passes the entire buffer straight to decodeAudioData()
|
src/wavesurfer.js:1586-1602 |
The wrapper guards a destroyed instance and a superseded buffer. No way to cancel a decode in flight |
src/wavesurfer.js:1612-1629, src/util/fetch.js:7-40
|
The 'progress' event measures the download. Web Audio exposes no decode progress at all |
src/wavesurfer.js:1505-1512, :1554-1559
|
Supplying peaks short-circuits both load paths |
src/wavesurfer.js:1528-1577 |
loadMediaElement() decodes anyway when no peaks are given |
That last row is the trap. Reading the docs, you'd assume backend: "MediaElement" opts out of Web Audio and therefore out of the decode, since streaming is the whole reason that backend exists. It doesn't.
Without peaks, inside loadMediaElement():
// If no pre-decoded peaks are provided, or are provided with
// forceDecode flag, attempt to download the audio file and decode it
// with Web Audio.
if (
(!peaks || this.params.forceDecode) &&
this.backend.supportsWebAudio()
) {
this.getArrayBuffer(url, arraybuffer => {
this.decodeArrayBuffer(arraybuffer, buffer => {
this.backend.buffer = buffer;
this.backend.setPeaks(null);
this.drawBuffer();
this.fireEvent('waveform-ready');
});
});
}
With peaks, twenty lines up in the same file:
setPeaks(peaks, duration);
drawBuffer();
fireEvent('waveform-ready');
Then it returns. No download, no decode, no ArrayBuffer at all. The two paths sit that close together, and the only thing choosing between them is whether you passed peaks.
wavesurfer's own docs say both halves out loud
None of this is hidden. The troubleshooting page for the current version:
By default wavesurfer decodes the audio in the browser (slow for long files). Pre-generating peaks on the server lets the waveform render instantly.
The Web Audio API requires the complete audio file before it can decode and generate peak data. There is no streaming decode path in the browser.
Read the word the docs land on. Slow. The bug trackers say crash and the arithmetic says why, so the folklore version of this failure sits in the library's own maintained docs.
I'm not scoring a point off that. I opened this investigation believing the same thing.
And the cost lives somewhere you can't reach anyway: inside the dependency, in a call you never make yourself. You can't wrap it, you can't schedule it. The only lever from outside is not calling it.
v7: the same path, in fewer lines
Most readers are on v7, so the version I read matters. I checked 7.12.11, the current release, and the decode path has the same shape.
/** Decode an array buffer into an audio buffer */
async function decode(audioData: ArrayBuffer, sampleRate: number): Promise<AudioBuffer> {
const audioCtx = new AudioContext({ sampleRate })
try {
return await audioCtx.decodeAudioData(audioData)
} finally {
// Ensure AudioContext is always closed, even on synchronous errors
if (audioCtx.state !== 'closed') {
await audioCtx.close().catch(() => undefined)
}
}
}
That's src/decoder.ts whole. Above it, src/wavesurfer.ts:532-585 fetches the entire file as a blob and passes the ArrayBuffer down, on the condition that no pre-decoded data was supplied. The v8 beta has the same shape.
The finally block deserves credit, and it also marks the ceiling:
closes the AudioContext on every path -> bounds what piles up ACROSS decodes yes
whole file still becomes an AudioBuffer -> bounds the peak DURING one decode no
Earlier I cited a report that each decode leaves roughly 100 MB behind on a plain AudioContext. v7 fixed the half it could reach.
v7's own performance page states it without hedging:
Decoding a long audio file with the Web Audio API allocates the entire file as raw PCM in memory — a 60-minute stereo track can easily exceed 500 MB.
That's the maintainers describing the mechanism and the order of magnitude. It reads as a conservative floor rather than a confirmation of my figure, since the arithmetic above puts an hour of stereo at roughly 1.27 GB, which does exceed 500 MB. We agree on what gets allocated, not on the exact number.
Somebody hit this on v7 after the general release. Issue #3647: a 120-minute file, backend: 'MediaElement', no peaks, tab crashed, with two duplicate reports in the same thread. It closed without a documented fix.
wavesurfer v6.6.4 and v7.12.11, side by side
| v6.6.4 | v7.12.11 | |
|---|---|---|
Whole file decoded when no peaks
|
Yes | Yes |
backend option |
Present | Removed in 7.0.0, restored three months later in c912f856, present again. Selects the playback engine only |
peaks and duration escape hatch |
Present | Present |
partialRender |
An option, default false
|
Removed. Lazy canvas drawing is the default |
One row there invites a wrong conclusion. partialRender disappeared in v7 because lazy drawing became the default, which sounds like the memory problem got handled. It didn't: the canvases drawn lazily are slices of this.decodedData, the buffer already holding the entire decoded file.
What the v6 to v7 upgrade actually costs
The official guide calls the v6 to v7 migration "about ten minutes", while the v7.0.0 notes remove 15 or more options, rename or drop 10 or more methods, and swap out three plugins. Price that yourself.
None of it touches the peak allocation. Upgrade for the things v7 genuinely does better, just not for this one, and don't read any of it as the library's fault. That finally block is what caring about memory looks like when the API leaves you only that much room, which is also why the library's own docs keep saying what they say.
Misunderstanding 5: getPeaks() is a second bottleneck, this one on the main thread
The truth: its loop is bounded by canvas width, not file length
Four claims down, all of them other people's. This one is mine. I assumed getPeaks() walked decoded samples once per canvas pixel, so that a five-hour file meant a five-hour loop.
Reasonable guess. It's also wrong.
if (this.peaks) { return this.peaks; }
...
sampleStep = ~~(sampleSize / 10) || 1
The step scales with the file. So the inner loop runs about ten times per canvas pixel no matter how long the audio runs, and total work is canvas width times ten times channels. Five hours, three minutes: same cost.
What survives is smaller than I expected, but it isn't zero. drawBuffer() with partialRender left at its default false draws in one synchronous pass, which is real main thread work proportional to canvas width. maxCanvasWidth: 4000 splits the waveform across several <canvas> elements to dodge browser width limits, and does nothing to spread that draw across frames.
Where the work actually happens
| Stage | Runs on | Cost | Scales with file length |
|---|---|---|---|
| Fetch the file | Network | The whole compressed file, held in memory | Yes, though I have no evidence it contributes to the crash |
decodeAudioData() |
Decoding thread, never main | 352,800 bytes per second of audio | Yes. This is the one that kills |
getPeaks() |
Main thread | ~10 samples per canvas pixel | No |
drawBuffer() |
Main thread | One synchronous pass | No. Proportional to canvas width |
So the main thread isn't idle here. It just isn't the thing that scales with the file.
What the industry does instead: precompute on a server
The players shipping audio at scale don't decode in the browser to draw a waveform. SoundCloud, YouTube Music and Mixcloud all render from peak data computed on a server and delivered as a small array of numbers. The waveform you see on SoundCloud arrived as numbers, not as audio.
The tooling is old and boring, which is the good kind. BBC R&D's audiowaveform reads MP3, WAV, FLAC, Ogg Vorbis or Opus and emits peaks as JSON or as a compact binary .dat.
# once per file, on your server, on hardware you picked
audiowaveform -i test.mp3 -o test.dat -z 256 -b 8
audiowaveform -i test.flac -o test.json -z 256 -b 8
# and .dat converts to JSON later without touching the audio again
audiowaveform -i test.dat -o test.json
-z is how many samples collapse into one point, -b is the bit depth of each one. The tool is GPL-3.0, which matters less than it sounds here because it runs as its own process on your server rather than shipping inside anything you serve.
If you'd rather build the pipeline yourself, ffmpeg will decode to raw PCM and you extract the peaks per bucket in whatever language your backend speaks. Either way the expensive decode happens once, instead of once per visitor on a phone you didn't pick.
That's also the honest answer to why the industry never needed any of the fixes above. It isn't that they found a better way to decode in a browser. They stopped.
Live streams break differently
A size check alone won't cover you. There is no streaming decode path in the browser, in the docs' own words, and a live stream never presents a last byte:
no Content-Length, body never ends
-> the fetch never completes
-> 'ready' never fires
-> the waveform waits forever
Nothing crashes and nothing runs out of memory. It simply never arrives.
| Input | What decoding does | What to send instead |
|---|---|---|
| Short file | Decodes fine | Nothing. Let it decode |
| Long file | Allocates ~1.27 GB per hour of stereo | Precomputed peaks |
| Live stream | Never finishes. 'ready' never fires |
No waveform at all. A plain seekable bar |
Decoding a long file in a browser to draw a picture of it is the wrong layer for the job. You're moving hundreds of megabytes of samples through a device's memory to produce a few thousand numbers, and those numbers are identical for every visitor. Compute them once.
What I shipped, and what broke after
In 2024 someone opened issue #23 on react-modern-audio-player: "How can i load peaks for the track?" Two years, no answer.
What eventually went out across v2.4.0 through v2.4.3 is less a feature than a refusal. Over a threshold, don't call wavesurfer.load() at all.
The gate, and where its numbers came from
isLive prop, or duration === Infinity -> live # the body never ends, so a decode can never finish
duration > 30 min -> faux # from the duration prop, else loadedmetadata
size > 50 MiB -> faux # 52.4 MB decimal, read from a HEAD content-length
otherwise -> normal # decode and draw the real waveform
HEAD probe: 8s timeout, fails open -> normal
content-length hidden by CORS -> normal, never assumed small
That block is pseudo-code for readability. The running version is one hook of 157 lines: useWaveformMode.ts.
Both numbers come from the arithmetic, not from a measurement. Redo it for your own users:
memory budget / 352,800 bytes per sec = duration ceiling
~100 MB one mid-range phone -> 4.7 min too aggressive: strips ordinary music
30 min what shipped -> ~6x that budget, a deliberate compromise
the byte gate asks the same question earlier, before duration is known
50 MiB at 128 kbps -> ~55 min
Earlier, because a HEAD response can arrive before loadedmetadata does.
Failing open is the part worth arguing about. Failing closed would have been easier to reason about and worse to live with: a three-minute song on a slow network would silently lose its waveform. An unknown size is not assumed small, for the same reason.
The gate also had to win a race against itself:
HEAD probe (async) ----------------> size known
loadedmetadata ---> load(), decode starts <- too early to decide anything
A sizeGatePending flag holds load() until the probe resolves. That one surfaced three weeks before the release, while the gate was still being built.
What broke after release
Shipping wasn't the end of it. One thing broke that the gate had nothing to say about, and it took until v2.4.2 to surface: fallback mode switched on by a long track stayed on for the next, shorter one.
The fix resets isLoadedMetaData when the track changes. The exception is the half worth reading:
one-track repeat-all loop
-> next index == current index
-> <audio> never reloads
-> loadedmetadata never fires again
-> reset the flag here and the bar stays dead for the rest of the loop
The peaks prop is the other half. Hand it the amplitudes your server computed and the real waveform draws, with no client decode at all:
<AudioPlayer playList={[{ id: 1, src, peaks, duration }]} />
That is the whole handoff. peaks is the array audiowaveform emitted; passing duration alongside it lets the gate decide before loadedmetadata ever arrives.
npm install --save react-modern-audio-player
A running demo: CodeSandbox.
So: don't hold it all at once
The main thread was never the constraint. Holding the entire file decoded at once is — and that is the part you get to refuse.
That is the single sentence the whole investigation collapses into. What it looks like in practice:
| If you have | Do this |
|---|---|
| A server, or any build step | Precompute peaks with audiowaveform or ffmpeg. One decode per file, ever, on hardware you chose |
| Neither | Gate on duration and byte size, then fall back to a plain seekable bar. A missing waveform beats a dead tab |
| A live stream | No waveform. There is no last byte to decode, so nothing will ever finish |
| Only Safari 26+ users | WebCodecs in a Worker, plus a demuxer and a codec support check |
And the one thing to stop doing: reaching for a Worker to run decodeAudioData. That isn't a weak fix, it's an unwritable one. A Worker is exactly where WebCodecs belongs — the thread was never the problem, the all-at-once allocation was.
What I couldn't check
Everything above comes from one version of one library's source, the text of a specification, and arithmetic. I didn't measure a heap. I didn't record a trace.
Someone with a profiler and a device lab could tell you where the real edges sit on a mid-range Android, and that person isn't me. The thresholds bother me most. The arithmetic says 30 minutes is already generous on a phone and cautious on a desktop, and one number has to serve both, so what went out is a compromise nobody measured. A memory budget nobody publishes is a strange thing to hang a default on.
The part that outlives the audio
The misdiagnosis is the small part of this. The bigger one is the order I thought in: slow, therefore move it off the main thread, with no step in between where anybody checks what is actually slow.
I did that too, and the bottleneck I invented is the record of it, a fix picked before a cause was read. So what am I holding a fix for right now, without having looked?
One narrow question while I'm asking: if you already run server-side peaks, do you generate them at upload time, or on request with a cache?
Top comments (0)