npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@motionvector/webcodecs-census

v0.3.1

Published

Find leaked VideoFrames, AudioData and codecs in WebCodecs apps — including inside Web Workers — and get the allocation stack that caused them.

Downloads

704

Readme

@motionvector/webcodecs-census

npm provenance licence

Find leaked VideoFrames, AudioData and codecs, and get the line of code that allocated them.

WebCodecs objects hold resources from a finite pool outside the JS heap. The garbage collector never reclaims them — only close() does. Nothing in the platform tells you that you leaked one, how many, or where from: the app just gets slower, then quietly stops decoding.

This is the instrumentation core. It runs in any context — page, dedicated worker, shared worker — and has no dependencies.

Instrumenting a worker you did not write, from outside the app, needs @motionvector/webcodecs-census-cdp. This package is what you reach for when you can edit the code being measured.

Install

npm install --save-dev @motionvector/webcodecs-census

Use

Install it as early as you can in every context that touches media — the main thread and each worker. Anything allocated before it installs is invisible to it.

import { installCensus, localCensus } from '@motionvector/webcodecs-census';

installCensus({ context: 'decoder-worker' });

Then ask what is still open:

const census = localCensus();
// {
//   live:       { VideoFrame: 58, VideoDecoder: 1 },
//   entered:    { 'VideoFrame:decoded': 238, 'VideoDecoder:constructed': 1 },
//   left:       { 'VideoFrame:closed': 179 },
//   leakSites:  [ { count: 58, type: 'VideoFrame', origin: 'decoded', stack, oldestAgeMs } ],
//   collectedUnclosed: { VideoFrame: 1 },
//   mediaElements:     { total: 4, stalled: 1, byReadyState: { 0: 1, 4: 3 } },
//   timeline:   [ … ],
// }

leakSites is the part that matters: live objects grouped by where they entered the context, worst first. A count alone cannot be acted on.

Make a leak fail the build

import { expectNoLeakedFrames } from '@motionvector/webcodecs-census';

test('the editor releases every frame it decodes', async () => {
  await playThroughTimeline();
  expectNoLeakedFrames([localCensus()], { minAgeMs: 1000 });
});

checkLeaks() returns the same information without throwing.

minAgeMs ignores live objects younger than the threshold — a decode in flight is not a leak. It is applied to the verdict, not only to the sites the report prints, because the census carries the age of every live object in liveAges. If a census predates 0.3.0 it has no ages; the report then leaves the counts unfiltered, sets minAgeMsApplied to false and names the contexts it could not filter, rather than letting an ignored option read as a clean bill of health.

The ages are capped, oldest first, and the cap travels in the payload as liveAgesCap. Keeping the oldest is what makes the count exact: anything dropped is younger than the youngest age kept, so it cannot clear a threshold the kept ages already fall below. If every kept age does clear it, the count is honest about being a lower bound — at least 256 VideoFrame still live … 9000 live in total — with the exact total in liveBounded.

types decides what counts as live too long. It defaults to the frame-like types, because a long-lived decoder is normal and a long-lived frame almost never is. Pass types: 'all' to hold the codecs to the same standard:

expectNoLeaks([localCensus()], { types: 'all' });

What the platform does behind your back

A codec that fails is closed by the platform, not by your code: the spec closes it before it invokes your error callback, so no close() call happens. The census reconciles live codecs against their own state, records that as closedByPlatform, and does not report it as a leak. Getting this wrong is how a leak detector invents leaks in an error-heavy pipeline.

The mirror image is worth catching. Closing an already-closed codec throws InvalidStateError — the four codec types throw, the three frame types are idempotent — and from a floating .finally() that becomes an unhandled rejection no application code can catch. Those calls are counted in overCloses, with the line that made them:

3 close() call(s) threw — a codec was closed twice:
  3x VideoDecoder: Cannot call 'close' on a closed codec (in worker)
      at decodePump (pipeline.js:812:20)

It is a lifecycle defect, not a leak, so it never fails a check on its own. Pass failOnOverClose: true for code you own.

Two things types deliberately does not do. It never hides an object the GC collected while it was still open — that is the definitive leak, and it fails the check whatever its type. And it never lets the report claim a clean bill of health for a type it did not look at: an unenforced type with live objects is named in the message.

No leaks in VideoFrame, AudioData, ImageBitmap — but VideoDecoder=47 still
live and not enforced. Pass types: 'all' to check those too.

What it counts, and why that is not obvious

Tracked: VideoDecoder, VideoEncoder, AudioDecoder, AudioEncoder, VideoFrame, AudioData, ImageBitmap — plus <video>/<audio> elements, because Chrome caps WebMediaPlayers per frame and elements past the cap stall at readyState 0 with no error event.

Each live object records how it entered the context, because provenance decides whether a leak is yours:

| Origin | Meaning | | --- | --- | | constructed | new VideoFrame(...) here | | decoded | produced by a codec, attributed to that codec's construction site | | cloned | .clone() — an independent handle needing its own close() | | received | arrived over postMessage; this context owns it now |

decoded is the one that catches real bugs. Frames that leak in production never pass through a JS constructor — the platform creates them and hands them to the output callback you gave new VideoDecoder({output}). An instrument that only traps the constructor reports a clean pipeline for an app losing every frame. This wraps that callback, and since a platform callback has no application frames above it, attributes each frame to the codec that produced it.

Transfers are accounted for explicitly. Transferring a VideoFrame detaches the sender's handle without calling close(), and the receiver gets it by structured clone rather than a constructor. Counted naively that is a false leak in one context and an invisible object in the other.

A FinalizationRegistry catches the unambiguous case: an object collected by GC that was never closed. That is a leak, not a heuristic.

The timeline, and why a snapshot lies

A static count says how many are live. It cannot say whether the pipeline was busy — and that difference is what tells a backlog apart from a wedge. Every context keeps a rolling sample of live counts, codec throughput, queue depth and media-element readyState:

   t(s)  liveVF  dec/out  queued
     12      59    0/0        0
     58      58    0/0        0
    162      58    0/0        0

Decoder idle, queue empty, frames frozen: wedged, not buffering. A snapshot alone reads that as normal.

Cost

Measured, not estimated: +5.6 µs per tracked allocation and ~284 bytes per live tracked object, the latter bounded by the size of the leak itself. At 60 fps that is 0.03% of a second. It only matters above roughly 100k allocations per second.

Suitable for development and CI. Not recommended enabled by default in production builds — not for speed, but because it patches global constructors and retains a stack per live object.

API

| | | | --- | --- | | installCensus(options?) | Patch this context. Safe to call more than once. | | localCensus() | Snapshot this context. | | timeline() | The rolling samples on their own. | | checkLeaks(censuses, options?) | A pass/fail report, without throwing. | | expectNoLeaks(censuses, options?) | Throws with the allocation sites. | | expectNoLeakedFrames(censuses, options?) | The common case, named for what it means. | | summarize(censuses) | A compact digest, sized for an agent to read. | | totalLive(censuses, type) | Sum one type across contexts. | | resetCensus() | Clear counters without unpatching. Tests only. |

installCensus accepts context, sampleIntervalMs, keepSamples, stackDepth and warnOnCollect.

No network, no dependencies

The core makes no requests of any kind: no telemetry, no beacons, no reporting. Verify with grep -rE 'fetch\(|sendBeacon|WebSocket' node_modules/@motionvector/webcodecs-census/dist.

Published from GitHub Actions with provenance, so every release is traceable to the commit and workflow that built it.

Documentation

Full documentation, the two-phase worker injection, and the honest limits: github.com/motionvector-dev/webcodecs-census

MIT