DEV Community

Cover image for Five Misunderstandings About Decoding Long Audio in the Browser
MUSGRAVTE
MUSGRAVTE

Posted on

Five Misunderstandings About Decoding Long Audio in the Browser

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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');
        });
    });
}
Enter fullscreen mode Exit fullscreen mode

With peaks, twenty lines up in the same file:

setPeaks(peaks, duration);
drawBuffer();
fireEvent('waveform-ready');
Enter fullscreen mode Exit fullscreen mode

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)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

-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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }]} />
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)