loke.dev
Header image for The VideoFrame Leak: Why Your Custom Canvas Player Is Silently Choking Your GPU Memory

The VideoFrame Leak: Why Your Custom Canvas Player Is Silently Choking Your GPU Memory

The WebCodecs API offers surgical control over raw video bitstreams, but mastering the explicit lifecycle of hardware-accelerated frames is the only way to prevent catastrophic memory exhaustion.

· 7 min read

You’ve spent weeks building a frame-accurate video editor or a low-latency surveillance dashboard in the browser, only to watch your user’s Chrome tab vanish into a "Status Code: Aw, Snap!" after five minutes of playback. If you are moving away from the standard <video> tag in favor of the WebCodecs API and <canvas>, you have effectively traded a high-level, managed "automatic" transmission for a manual gearbox that requires you to synchronize every single gear shift yourself.

The performance gains of using VideoDecoder and VideoFrame are massive, but they come with a brutal requirement: you are now the memory manager. Unlike standard JavaScript objects, a VideoFrame isn't just a bit of data in the heap; it is a handle to a hardware-accelerated resource. If you don't explicitly release it, your GPU memory will bloat until the browser’s multi-process architecture decides to kill your tab to save the OS.

The Mental Model: Handles, Not Objects

In typical JavaScript development, we’re spoiled. We create objects, use them, and let the Garbage Collector (GC) sweep up the mess when we're done. But VideoFrame objects are different. Think of a VideoFrame as a physical key to a locker at the gym. The key itself is small (the JS object), but the locker it opens (the GPU buffer) is huge.

The Garbage Collector is very good at seeing that you’ve dropped the key on the floor, but it’s incredibly slow at realizing that the locker is still locked and taking up space. By the time the GC gets around to recycling the "key" object, your GPU has already run out of "lockers," and the browser crashes.

This is why the close() method exists. It’s the only way to tell the hardware, "I am done with this buffer, give it back immediately."

The Anatomy of a Leak

Let’s look at a common pattern that looks correct but is actually a ticking time bomb. This is a simplified VideoDecoder output callback:

// WARNING: This code leaks memory
const decoder = new VideoDecoder({
  output: (frame) => {
    // We send the frame to our rendering function
    renderToCanvas(frame);
  },
  error: (e) => console.error(e),
});

function renderToCanvas(frame) {
  const ctx = canvas.getContext('2d');
  // Drawing the frame to the canvas
  ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
  
  // Logic continues... but the frame is never closed!
}

In the example above, every time the decoder spits out a frame (usually 30 or 60 times per second), a new VideoFrame object is created. By calling drawImage, we’re using the frame, but we aren't releasing it. Within seconds, you could have hundreds of 4K textures sitting in VRAM, even if the JS heap only shows a few megabytes of usage.

The Correct Way: The Explicit Close

To fix this, we must call .close() as soon as the frame is no longer needed.

function renderToCanvas(frame) {
  try {
    const ctx = canvas.getContext('2d');
    ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
  } finally {
    // This is the most important line in your player
    frame.close();
  }
}

Using a try...finally block is non-negotiable. If your rendering logic throws an error (perhaps due to a canvas resize or a lost context) and you skip the .close() call, that frame is leaked forever.

The Clone Trap

Things get significantly more complicated when you need to use the same frame in multiple places. Perhaps you are rendering the frame to the screen *and* sending it to a WebWorker for some AI processing or face detection.

You might be tempted to do this:

output: (frame) => {
  renderToCanvas(frame);
  worker.postMessage({ type: 'process', frame }); // This will fail or cause issues
}

When you postMessage a VideoFrame, you can "transfer" it, which hands over ownership to the Worker. Once transferred, you can no longer use it in the main thread to draw to your canvas. If you don’t transfer it, the browser has to make a copy, which is computationally expensive.

The solution is frame.clone().

Cloning a VideoFrame doesn't usually copy the actual pixel data in GPU memory; instead, it creates a new "handle" pointing to the same underlying buffer. It increments a reference count. This means you now have *two* objects that must both be closed before the memory is actually freed.

output: (frame) => {
  // Create a clone for the worker
  const frameForWorker = frame.clone();
  
  // Send the clone to the worker, transferring ownership
  worker.postMessage({ type: 'process', frame: frameForWorker }, [frameForWorker]);
  
  // Use the original frame for local rendering, then close it
  renderToCanvas(frame); 
  // (Assuming renderToCanvas handles the close() internally)
}

In the Worker, you must also be diligent:

// Inside worker.js
self.onmessage = (e) => {
  const { frame } = e.data;
  try {
    analyzeFrame(frame);
  } finally {
    frame.close(); // If you don't do this, the worker thread will choke the GPU
  }
};

Why requestVideoFrameCallback is Your Best Friend

If you are building a custom player, you are likely using requestAnimationFrame (rAF) to sync your canvas draws with the display refresh rate. However, video frames don’t always arrive at the same cadence as your monitor’s refresh rate.

For a smoother experience and better memory management, use HTMLVideoElement.requestVideoFrameCallback() (rvfc) if you are still using a hidden <video> element as a source, or a robust queue if you're using VideoDecoder directly.

If you are pulling frames from a VideoDecoder and storing them in an array to manage jitter (a "jitter buffer"), you are holding onto GPU memory for every frame in that array.

let frameQueue = [];

const decoder = new VideoDecoder({
  output: (frame) => {
    frameQueue.push(frame);
    // If our queue gets too long, we are eating GPU memory
    if (frameQueue.length > 30) {
      const droppedFrame = frameQueue.shift();
      droppedFrame.close(); 
      console.warn("Dropped frame to save memory");
    }
  }
});

Without that droppedFrame.close(), a large jitter buffer isn't just a memory usage spike; it’s a leak that grows until the tab dies.

The "Invisible" Leaks: Error Handling and Early Returns

I’ve seen many developers lose hours debugging leaks that only happen during seeking or when the user switches tabs.

Consider a scenario where you're processing frames in a loop. If an async operation fails or a condition triggers an early return, do you still have a handle to that frame?

async function processVideo(reader) {
  while (true) {
    const { value: frame, done } = await reader.read();
    if (done) break;

    const metadata = await getMetadata(frame); 
    if (!metadata.isValid) {
      // LEAK! The frame was never closed before returning
      return; 
    }

    render(frame);
    frame.close();
  }
}

This is where the new Explicit Resource Management proposal (the using keyword in TypeScript 5.2+ and coming to standard JS) becomes a lifesaver. It allows you to bind the lifetime of a resource to the block scope.

// The future of WebCodecs memory management
async function processVideo(reader) {
  while (true) {
    const { value: frame, done } = await reader.read();
    if (done) break;

    // When 'frame' goes out of scope, frame.close() is called automatically
    using autoFrame = frame; 

    const metadata = await getMetadata(autoFrame);
    if (!metadata.isValid) return; // No leak!

    render(autoFrame);
  }
}

Until using is universally supported without transpilers, you must remain paranoid. Wrap your frame logic in try...finally as a rule of thumb, not an exception.

Measuring the Damage

How do you know if you're leaking? The standard Chrome DevTools "Memory" tab is surprisingly unhelpful here because it primarily tracks the V8 heap. A VideoFrame object is tiny in the heap.

To see the real impact, you need the Chrome Task Manager:
1. Open the Chrome menu (three dots) -> More Tools -> Task Manager.
2. Right-click the headers and enable GPU Memory.
3. Watch your tab’s GPU memory as your player runs.

If you see the GPU memory climbing steadily like a mountain range, you have a VideoFrame leak. If it stays relatively flat or "saws" (goes up and then drops sharply when the GC finally kicks in), you are doing okay, but you should still aim for a flat line by calling .close() more aggressively.

Beyond Pixels: AudioData Leaks

While this post focuses on video, it’s worth noting that the WebCodecs AudioData object behaves exactly the same way. If you are building a custom audio player using AudioDecoder, you must call audioData.close() after copying the buffer to your AudioWorklet or AudioBufferSourceNode. Audio samples take up less space than 4K video frames, so the crash takes longer to happen, making it an even more insidious bug to track down.

Managing Pressure

The VideoDecoder itself has a "backpressure" mechanism. The decoder.decodeQueueSize property tells you how many requests are waiting to be processed. If you are pumping data into the decoder faster than the hardware can spit out frames, you're building up a different kind of memory pressure.

A well-behaved player monitors decodeQueueSize and pauses the ingestion of new chunks until the hardware catches up.

async function pumpData(stream) {
  for await (const chunk of stream) {
    while (decoder.decodeQueueSize > 5) {
      // Wait for the hardware to catch up before adding more work
      await new Promise(r => setTimeout(r, 10));
    }
    decoder.decode(chunk);
  }
}

Summary Checklist for a Leak-Free Player

1. Always `close()`: Every frame received in an output callback must be closed.
2. `try...finally` is Mandatory: Don't let an exception skip your cleanup logic.
3. Clone with Care: If you clone() a frame, you now have *two* problems. Close both.
4. Transfer Ownership: Use the second argument of postMessage to transfer frames to Workers so you don't have to manage them in two places.
5. Monitor GPU Memory: Use the Chrome Task Manager, not just the DevTools Heap Snapshot.
6. Handle Jitter Buffers: If you store frames in an array for sorting or smoothing, ensure you close any frame that gets dropped or cleared.

WebCodecs gives us the power to build incredible video experiences that were previously impossible in a browser. But with great power comes the responsibility of manual memory management. Treat every VideoFrame as a heavy, expensive resource that you’ve borrowed from the hardware, and return it the millisecond you’re done. Your users (and their laptop fans) will thank you.