AudioBuffer and Web Audio Memory Growth
A music, podcast or game page loads a handful of audio files and the tab’s memory jumps by hundreds of megabytes; on phones it crashes, and every time a player component mounts, memory rises again. This guide from Typed Arrays, Buffers and External Memory, in JavaScript Memory Fundamentals & Runtime Mechanics, explains why decoded audio is so large, which Web Audio objects hold memory outside the JavaScript heap, and how to keep audio-heavy pages within budget.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
| Memory jumps ~15× the file size after decoding | decodeAudioData stores uncompressed 32-bit float PCM per channel |
Decode only short clips; stream long audio via media elements | Memory proportional to clip length, not track length |
| Memory grows each time a player mounts | New AudioContext per mount, never closed |
Reuse one context or call close() on unmount |
Context resources released |
| Decoded buffers retained after tracks change | AudioBuffers cached in a map without eviction |
Bound the buffer cache; drop references on track change | Memory returns after switching tracks |
| Heap snapshot looks small, tab footprint huge | PCM data lives in backing stores outside the JS heap | Estimate PCM size from duration × rate × channels × 4 | Correct attribution of memory |
| Mobile crashes when preloading many sound effects | All effects decoded at full sample rate upfront | Preload lazily, downsample or use mono | Lower peak on low-memory devices |
Root Cause: Decoded Audio Is Uncompressed Floating-Point Data
Audio files are compressed — MP3, AAC and Opus reach roughly a tenth or less of the size of raw audio. The Web Audio API, however, processes samples, not compressed frames. AudioContext.decodeAudioData() decodes the entire file into an AudioBuffer holding 32-bit floating-point samples for every channel at the context’s sample rate. The size is easy to compute: duration × sample rate × channels × 4 bytes. A three-minute stereo track at 48 kHz is 180 × 48,000 × 2 × 4 ≈ 69 MB, from a file of about 4–6 MB. An hour-long podcast decoded this way would need over a gigabyte.
That memory lives in Float32Array backing stores outside the V8 heap, the same external-memory category as ArrayBuffer and Blob memory outside the JS heap. Heap snapshots show small AudioBuffer objects; the Task Manager footprint shows the real cost. Decoding also creates a temporary peak: the compressed ArrayBuffer you fetched, the decoder’s working memory and the output buffer can all exist at once.
Beyond buffers, the AudioContext itself owns resources: an audio rendering thread, a connection to the system audio device and internal buffers. Browsers limit how many contexts a page can hold, and a context stays alive until you call close() or the page goes away. Components that create a new context on every mount and never close it accumulate those resources. Audio graph nodes are lighter, but source nodes that are started and never stopped, or graphs that remain connected to a live context and referenced from JavaScript, keep their buffers reachable.
The right tool depends on the audio. Short clips — sound effects, UI sounds, samples for synthesis — belong in AudioBuffers, decoded once and reused. Long audio — music, podcasts, voice recordings — should play through an <audio> element (optionally routed into Web Audio with createMediaElementSource), which streams and decodes incrementally so only a small window of decoded audio exists at any time.
Step-by-Step Fix
- Estimate decoded sizes. For every
decodeAudioDatacall, compute duration ×context.sampleRate× channels × 4 bytes. Verification: you know which assets decode to tens or hundreds of MB. - Stream long audio. Play music and speech with
<audio>/HTMLMediaElement, routed throughcreateMediaElementSourceif you need Web Audio effects. Verification: the Task Manager footprint no longer jumps by the full decoded size when a long track starts. - Share one AudioContext. Create a single context for the page (or per feature) and reuse it; if a component must own one, call
await ctx.close()on unmount. Verification: mounting and unmounting the player ten times does not increase memory or the number of contexts. - Bound the AudioBuffer cache. Keep decoded effects in a size-bounded cache keyed by URL, and drop buffers for scenes or tracks no longer in use. Verification: total decoded bytes in the cache stay under a budget you define.
- Reduce decoded size where quality allows. Use mono for effects, trim silence, and create the context at a lower
sampleRate(for example 22,050 Hz) for games that do not need full fidelity. Verification: decoded sizes halve or better. - Stop and disconnect finished sources. Call
stop()on looping sources when done,disconnect()graph branches you remove, and drop references. Verification: after a scene change, memory returns close to its pre-scene level.
Command and Code Reference
Use case: estimate and budget decoded audio before decoding.
// Bytes an AudioBuffer will need: duration × rate × channels × 4 (float32)
function decodedBytes(durationSec, sampleRate, channels) {
return Math.ceil(durationSec * sampleRate) * channels * 4;
}
console.log((decodedBytes(180, 48000, 2) / 1048576).toFixed(1), 'MB'); // ~65.9 MB
Use case: one shared context and a bounded effects cache.
// audio.js
export const ctx = new AudioContext({ latencyHint: 'interactive' }); // one per page
const MAX_BYTES = 40 * 1048576; // 40 MB budget
const cache = new Map(); // url → AudioBuffer
let cachedBytes = 0;
export async function getEffect(url) {
if (cache.has(url)) {
const buf = cache.get(url);
cache.delete(url); cache.set(url, buf); // refresh LRU order
return buf;
}
const data = await (await fetch(url)).arrayBuffer(); // compressed bytes
const buf = await ctx.decodeAudioData(data); // float PCM
const bytes = buf.length * buf.numberOfChannels * 4;
cache.set(url, buf);
cachedBytes += bytes;
while (cachedBytes > MAX_BYTES && cache.size > 1) { // evict oldest
const [oldUrl, oldBuf] = cache.entries().next().value;
cache.delete(oldUrl);
cachedBytes -= oldBuf.length * oldBuf.numberOfChannels * 4;
}
return buf;
}
Use case: stream long audio through the same graph.
import { ctx } from './audio.js';
export function playTrack(url, destinationNode) {
const el = new Audio(url); // streams and decodes incrementally
el.crossOrigin = 'anonymous'; // needed for cross-origin processing
const source = ctx.createMediaElementSource(el); // route into Web Audio for effects
source.connect(destinationNode);
el.play();
return () => { // teardown
el.pause();
source.disconnect();
el.removeAttribute('src');
el.load(); // release the media resource
};
}
Verification and Regression Prevention
Verify with the Chrome Task Manager’s memory footprint (decoded audio is external memory) while exercising the page: starting a long track should not increase footprint by its full decoded size, switching scenes or tracks should return memory close to the previous level, and mounting the player repeatedly should not add memory. Test on a low-memory phone as well, following setting memory budgets for low-end devices, because decoded audio is one of the fastest ways to exceed mobile limits.
Keep an explicit decoded-audio budget in code, as in the cache above, and log total decoded bytes in development builds. Add a lint rule or code-review check that flags new AudioContext() inside components and decodeAudioData for assets longer than a threshold, so long tracks are always streamed.
Edge Cases and Gotchas
Sample-rate conversion on decode
decodeAudioData resamples to the context’s sample rate. A 22 kHz file decoded in a 48 kHz context more than doubles in size. Match the context rate to your needs, or decode short assets in an OfflineAudioContext at the desired rate.
AudioBuffer channel data copies
getChannelData() returns a view, but copyFromChannel and some processing patterns create copies. Avoid calling getChannelData(...).slice() in loops on long buffers.
Autoplay policies create extra contexts
Code that creates a new context after each user gesture to satisfy autoplay rules can accumulate contexts. Create one context, and call ctx.resume() on the gesture instead.
Offline rendering
OfflineAudioContext.startRendering() produces a new AudioBuffer of the full rendered length. Rendering long mixes offline has the same memory cost as decoding them; render in segments if possible.
Frequently Asked Questions
Why is my decoded audio so much bigger than the file?
Because decodeAudioData produces uncompressed 32-bit float samples for every channel at the context’s sample rate. Compressed formats are typically ten to twenty times smaller, so a 5 MB MP3 can become a 60–70 MB AudioBuffer.
Should I close AudioContext when a component unmounts?
Yes, if the component created it. Better still, share one context across the page and create only nodes per component. Unclosed contexts hold system audio resources and memory until the page is closed.
How do I play long audio with effects without decoding it all?
Use an <audio> element and connect it into Web Audio with createMediaElementSource(). The element streams and decodes incrementally, and the Web Audio graph can still apply gain, filters and analysers.
Do AudioBuffers show up in heap snapshots?
The AudioBuffer objects do, but their sample data is held outside the JavaScript heap, so their apparent size is small. Use Task Manager or process-level metrics to see the real memory cost.
Related
- Typed Arrays, Buffers and External Memory — the parent topic
- Canvas and ImageBitmap Memory in Long-Running Apps — the image equivalent of decoded media memory
- Why iOS Safari Reloads Your Tab Under Memory Pressure — what happens when media memory exceeds device limits
- JavaScript Memory Fundamentals & Runtime Mechanics — the section overview