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

overdraw

v0.2.0

Published

Find the GPU leak in your WebGL app and the line that caused it. One command watches your app while you use it; the same measurement becomes a CI budget. three.js, R3F, Babylon, PlayCanvas or raw WebGL.

Readme

Overdraw

Find the GPU leak in your WebGL app, then keep it out. One command opens your app, watches it while you use it, and tells you what leaked and which line allocated it.

npx overdraw watch localhost:5173

No config file. No selectors. Nothing to set up.

  leaks
  ✗ GPU memory growth / lap              2.0 MB  ≤ 0 B
  ✗ Undisposed resources / lap                8  ≤ 0
  ✗ rAF callbacks / frame                     4  ≤ 1
      more than one render loop is running — a previous loop was never cancelled
      GPU memory per lap: 4.0 MB → 6.0 MB → 8.0 MB
      after 100 laps this reaches +200.0 MB

  undisposed resources by allocation site
  1. 32 textures · 8.0 MB
     256×256
     openViewer (http://localhost:5173/src/Viewer.js:34:20)
     allocated during lap 1 ×8, lap 2 ×8, lap 3 ×8, lap 4 ×8

  how to fix
  1. 4 render loops are running at once  [critical]
     Each requestAnimationFrame chain re-registers itself forever. Starting a
     loop on open without cancelling the previous one means every open adds
     another loop rendering the same scene.

     • Keep the handle from requestAnimationFrame and call
       cancelAnimationFrame on teardown.
     • Guard the start function so calling it twice cannot create a second loop.

  2. Textures are leaking while geometry is not  [high]
     This asymmetry almost always means material.dispose() is being called and
     assumed to take the maps with it. It does not.

Why this exists

WebGL resources are not garbage collected the way the rest of your JavaScript is. Miss a dispose(), forget a cancelAnimationFrame, or never call renderer.dispose(), and GPU memory climbs until the browser drops the context. On desktop it looks like a slow leak. On an iPhone it's a blank canvas and a tab crash.

This is the single most recurrent class of bug in the three.js ecosystem. React Three Fiber's own performance pitfalls doc names it directly: the most common issues are too many draw calls, leaked GPU resources, or both. The same bug has been reported for eleven years straight — three.js #7449 (2015), #8102, #11988, #21151, #31644 (2026), and forum threads 6534, 47924, 51054, 18876.

It keeps coming back because every existing tool is a manual live debugger. Needle Inspector, Spector.js, stats-gl, renderer.info, Chrome DevTools — all of them require a human to open a panel, on a running page, and interpret what they see. renderer.info.memory gives you counts with no identity and no origin, and has reported negative numbers while doing it.

Overdraw answers a different question: which line of my code allocated the thing that never got freed.

Install

Nothing to install to try it:

npx overdraw watch localhost:5173

To keep it around, or to use it in CI:

npm install --save-dev overdraw
npx playwright install chromium

Watch: find the leak

npx overdraw watch localhost:5173

A browser window opens with a small panel in the corner showing live texture, buffer and memory counts.

Use the app the way a user would. Open the thing, close it, change routes, do whatever you suspect.

Every time you are back where you started, press back to start in the panel (or Alt+L). That marks a lap — one round trip that should have given the memory back. Two laps is enough to measure; four or five is better.

Close the window and Overdraw prints the report.

| | | | --- | --- | | back to start / Alt+L | Mark a lap | | reset / Alt+R | Start the measurement over from now | | --duration 30s | End the session on a timer instead of by closing the window |

If you never mark a lap, Overdraw falls back to growth over wall-clock and says so in the report. That is weaker evidence — a scene still streaming in a world grows too, without leaking — so mark laps when you can.

The panel lives in a closed shadow root, never touches WebGL, and never calls requestAnimationFrame, so it cannot alter the numbers it is reporting.

Run: keep it out

Once you know there's something worth guarding, turn the same measurement into a build gate.

For a one-off, skip the config file entirely:

npx overdraw run --url localhost:5173 --cycle "click:#open, wait:400, click:#close" --cycles 5

For something you'll keep:

npx overdraw init

Then edit overdraw.config.js. The important part is the cycle — the scripted version of a lap.

export default {
  server: { command: 'npm run dev', port: 5173 },

  scenarios: [
    {
      name: 'product-viewer',
      url: '/products/chair',
      warmup: { waitFor: 'canvas', settleMs: 2000 },
      cycle: [
        { click: '#open-viewer' },
        { waitMs: 1000 },
        { click: '#close-viewer' },
        { waitMs: 500 }
      ],
      cycles: 5
    }
  ],

  budget: {
    drawCalls: 150,
    textureBytes: '256MB',
    frameTimeP95: 20,
    leak: {
      totalBytesPerCycle: 0,   // the scene must give memory back
      rafCallbacksPerFrame: 1  // exactly one render loop
    }
  }
};
npx overdraw run

Exit code 0 if every budget is met, 1 if one is exceeded, 2 if the run itself failed.

In CI

- uses: actions/setup-node@v4
  with: { node-version: 20 }
- run: npm ci
- uses: Se1foo/overdraw@v1

The action installs Chromium, runs your config, writes a job summary, and uploads the HTML report as an artifact. Add fail-on-violation: false to report without blocking the merge while you tune budgets.

To track regressions rather than absolute limits, commit a baseline:

npx overdraw baseline

Subsequent runs print a before/after diff for every metric.

What it measures

| | | | --- | --- | | drawCalls | Draw calls per frame, averaged over the measured window | | triangles | Triangles per frame, instancing accounted for | | textureBytes bufferBytes totalBytes | GPU memory still allocated at end of run | | textures programs | Live resource counts | | shaderCompiles | Shader compiles — spikes here are jank | | contexts | WebGL contexts created; more than one per page is usually a leaked renderer | | frameTimeP95 frameTimeP99 | Frame time percentiles | | leak.totalBytesPerCycle | GPU memory growth per repetition of the cycle | | leak.resourcesPerCycle | Resources allocated and never freed, per cycle | | leak.rafCallbacksPerFrame | Render loops running at once — above 1 means one was never cancelled |

Sizes accept 512, '2.5MB', '1GB'. Any budget left unset is measured and reported but never fails the build.

Hard failures independent of budget: a lost WebGL context, and uncaught page errors.

It tells you how to fix it

Detecting a leak is half an answer. The pattern of what leaked is diagnostic, so Overdraw reads the combination of signals and names the cause:

| Signal | Diagnosis | | --- | --- | | Textures and buffers growing together | Objects detached from the scene without disposal | | Textures growing, buffers flat | material.dispose() was called and assumed to take its maps — it doesn't | | Buffers growing, textures flat | geometry.dispose() missing, or geometry rebuilt per frame | | Framebuffers / renderbuffers growing | A WebGLRenderTarget was never disposed | | Programs growing | New material variants each cycle — shader-key churn and compile stalls | | >1 rAF callback per frame | A render loop was never cancelled | | Contexts > 1 | A WebGLRenderer was rebuilt instead of reused | | Loader frames in the stack | Assets retained by a loader cache (useGLTF.clear()) | | shadow in the stack | Shadow-map render targets from removed lights |

Each finding comes with why it happens, the concrete steps, and a copy-pasteable fix (--verbose for snippets). Findings are ranked, with a lost context always first — since everything else is what caused it.

Run against the bundled leaking fixture, it independently identifies both seeded bugs: the stacked render loops as critical and the missing disposal as high, with the allocating line for each.

How it works

Overdraw injects a probe before your application code that patches WebGLRenderingContext and WebGL2RenderingContext — not three.js. So it works with three.js, React Three Fiber, Threlte, Babylon.js, PlayCanvas, or hand-written WebGL, and minification can't defeat it.

Every createTexture, createBuffer, createProgram, bufferData, texImage2D, and draw* call is accounted for, with a JavaScript stack captured at allocation time. Matching delete* calls retire the record. Whatever is left at the end of the run is what your scene never gave back.

The probe deliberately holds no strong reference to any live GL object — resources are tracked by integer id through a WeakMap. A probe that retained them would prevent collection and mask the very leaks it exists to find.

Honest limitations

Worth knowing before you trust a number:

  • "Leaked" means no matching delete call was made. Overdraw reports resources created without a corresponding deleteTexture/deleteBuffer/etc. Browsers may eventually reclaim an unreachable GL object via GC, but that timing is not tied to VRAM pressure — which is exactly why unpredictable OOM crashes happen on mobile, and why three.js requires manual disposal. This is the actionable signal, but it is not a driver-level VRAM reading.
  • Allocation sites point at the GL call, not the constructor. three.js allocates GPU resources lazily during render(), so a leaked texture is attributed to your render loop rather than to the new THREE.Texture() line. The cycle attribution (allocated during cycle 3) is usually what pins down the interaction. Resources you allocate through raw WebGL are attributed exactly.
  • Frame timing needs a real GPU. On a GPU-less CI runner Chromium falls back to SwiftShader. Overdraw detects this, marks the run, and skips timing budgets rather than reporting fiction. Resource and leak accounting is exact either way — which is the point: the leak gate works on any runner.
  • Texture memory is computed, not queried. Bytes are derived from the format, dimensions, and mip chain of each upload. Compressed formats use the exact supplied byte length. Driver-side padding and alignment are not visible to any web API.
  • Frames spanning a paused render loop are excluded from percentiles and counted separately as Frames excluded (loop paused), so an idle gap is never reported as a slow frame.
  • A watch session is only as good as its laps. A lap is your assertion that the app is back where it started; Overdraw cannot verify that. With no laps marked it falls back to growth over wall-clock, which a scene that is still streaming will trip without leaking. The report always states which of the two it used.

CLI

overdraw watch <url>             Open the app and watch it while you use it
overdraw run                     Replay a scripted cycle and check it against the budget
overdraw init                    Write a starter config
overdraw baseline                Measure and save as the new baseline

  -c, --config <path>            Config file
  -u, --url <url>                Target URL, overrides the config
  -s, --scenario <name>          Run only this scenario
      --cycle <steps>            Inline cycle, no config file:
                                 --cycle "click:#open, wait:400, click:#close"
      --cycles <n>               Override cycle count
      --duration <time>          End a watch session automatically, e.g. 30s
      --update-baseline          Save this run as the baseline
      --reporter <list>          terminal,json,html,github
      --headed                   Show the browser
      --no-gpu                   Force the software renderer
      --json                     Raw JSON to stdout
  -v, --verbose                  Full stacks and every leak site

--cycle steps are click, hover, press, goto, waitfor, scroll and wait (milliseconds), comma separated.

Programmatic use

import { measure } from 'overdraw';

const report = await measure({
  url: 'http://localhost:5173',
  scenarios: [{ name: 'home', cycle: [{ click: '#open' }, { click: '#close' }], cycles: 5 }],
  budget: { leak: { totalBytesPerCycle: 0 } }
});

if (!report.passed) {
  for (const s of report.scenarios) {
    for (const f of s.failures) console.error(`${s.name}: ${f.label} = ${f.actual} (budget ${f.limit})`);
  }
  process.exit(1);
}

observe is the same thing for a watch session — it opens a window, returns when the person closes it, and takes watch: { durationMs } to end on a timer instead.

import { observe } from 'overdraw';

const report = await observe({
  url: 'http://localhost:5173',
  watch: { durationMs: 30_000 }
});

console.log(report.basis, report.lapCount); // 'laps' 4

Development

npm install
npx playwright install chromium
npm test          # unit tests, then end-to-end against both fixtures
npm run selftest  # run the CLI against the bundled fixtures and eyeball the output

test/fixtures/leaky.html and test/fixtures/clean.html run an identical three.js workload — one leaks the way real apps leak, the other disposes correctly. The end-to-end suite asserts that Overdraw fails the first and passes the second. If it can't tell them apart, the tool is broken.

License

MIT