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.
Maintainers
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:5173No 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:5173To keep it around, or to use it in CI:
npm install --save-dev overdraw
npx playwright install chromiumWatch: find the leak
npx overdraw watch localhost:5173A 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 5For something you'll keep:
npx overdraw initThen 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 runExit 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@v1The 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 baselineSubsequent 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
deletecall was made. Overdraw reports resources created without a correspondingdeleteTexture/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 thenew 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' 4Development
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 outputtest/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
