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.

Compressed size versus decoded size A 2 second stereo sound effect is 30 kilobytes compressed and about 0.8 megabytes decoded at 48 kilohertz. A 3 minute stereo song is 5 megabytes compressed and about 69 megabytes decoded. A 1 hour mono podcast is 30 megabytes compressed and about 690 megabytes decoded, which is why long audio should be streamed through a media element. Compressed file vs decoded AudioBuffer (48 kHz, float32) 2 s effect, stereo 30 KB → 0.8 MB 3 min song, stereo 5 MB → 69 MB 1 h podcast, mono 30 MB → ~690 MB — stream it instead compressed

Step-by-Step Fix

  1. Estimate decoded sizes. For every decodeAudioData call, compute duration × context.sampleRate × channels × 4 bytes. Verification: you know which assets decode to tens or hundreds of MB.
  2. Stream long audio. Play music and speech with <audio>/HTMLMediaElement, routed through createMediaElementSource if you need Web Audio effects. Verification: the Task Manager footprint no longer jumps by the full decoded size when a long track starts.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
Decode fully or stream Left path: fetch the whole file, decodeAudioData into an AudioBuffer holding the entire track as float PCM, then an AudioBufferSourceNode plays it; memory equals the full decoded size. Right path: an audio element streams and decodes a small window at a time, createMediaElementSource connects it to the same Web Audio graph for effects; memory stays small regardless of track length. fetch whole file AudioBuffer entire track as PCM BufferSourceNode Effects graph gain, filters, analyser <audio> element streams, small window createMediaElement- Source() short clips: left · long audio: right

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.

Audio memory checks in Task Manager Decoded audio is external memory, so watch the Chrome Task Manager footprint. Starting a long track should not increase it by the full decoded size when streaming through a media element. Switching scenes or tracks should return memory close to the previous level. Mounting the player repeatedly should not add memory. Chrome Task Manager → Memory footprint Long track Footprint does not jump by the full decoded PCM size. Switch track or scene Returns close to the previous level. Remount player No growth; AudioContext closed on teardown.

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.