Discovered a slow steady memory growth leak through an accidental soak test. I left the device running for several hours and came back to a low framerate.
Environment
@nativescript/canvas 2.0.32 (master cd52d3d, 2026-05-07)
- Physical Android: Moto G 2025 (accurate oracle). Also reproducible on iOS simulator (noisier baseline).
- Renderer:
three WebGPU backend (three.webgpu.js) via @nativescript/canvas-three, and the
canvas plugin's own webgpuTriangle example.
- NativeScript runtime (V8) on both platforms.
Summary
Rendering WebGPU continuously (frameloop always) leaks native heap until the render loop stops.
Findings
Measured on a physical Moto G 2025, frameloop always, via on-device getNativeHeapAllocatedSize() and heapprofd with offline symbolization.
Three reference leaks (all native handles we held, none GC-related)
| # |
Leak |
Fix |
| 1 |
Per-frame transient wrappers (command encoder, render/compute pass, texture view, command buffer) freed only by the GC finalizer |
Release at the WebGPU consumption point (end/finish/submit/present); native handle held in ArcHandle so release is exactly-once |
| 2 |
present_surface read-back copy created a command encoder + command buffer every frame, never dropped |
command_buffer_drop + command_encoder_drop in the read-back block |
| 3 |
getCurrentTexture() registered a Texture + auto clear_view in wgpu-core's hub every frame; CanvasGPUTexture::drop was a no-op for surface textures |
texture_drop (and discard-if-unpresented) in the surface branch |
On-device result (foreground soak)
| build |
rate |
| baseline (released) |
OOM-restarts ~115 MB; ~47 KB/frame |
| + fix 1 |
0.31 MB/s |
| + fix 2 |
0.18 MB/s |
| + fix 3 |
+0.002 MB/s over 5 min — flat (= blank-app baseline) |
What it was NOT
- Not GC starvation:
global.gc() every 30 frames had no effect — the growth was
reachable native memory, not collectible JS.
- Not pooling / wrapper shells: heapprofd showed the C++/V8 shells were negligible;
the bytes were wgpu resources held in the hub.
Method
heapprofd growth analysis (per-callsite live-bytes delta, first vs last dump) + llvm-addr2line against an unstripped local build (LTO/ICF off so frames don't fold). Named the growers exactly: surface_get_current_texture (present.rs:220), device_create_command_encoder.
Discovered a slow steady memory growth leak through an accidental soak test. I left the device running for several hours and came back to a low framerate.
Environment
@nativescript/canvas2.0.32 (mastercd52d3d, 2026-05-07)threeWebGPU backend (three.webgpu.js) via@nativescript/canvas-three, and thecanvas plugin's own
webgpuTriangleexample.Summary
Rendering WebGPU continuously (frameloop
always) leaks native heap until the render loop stops.Findings
Measured on a physical Moto G 2025, frameloop
always, via on-devicegetNativeHeapAllocatedSize()and heapprofd with offline symbolization.Three reference leaks (all native handles we held, none GC-related)
end/finish/submit/present); native handle held inArcHandleso release is exactly-oncepresent_surfaceread-back copy created a command encoder + command buffer every frame, never droppedcommand_buffer_drop+command_encoder_dropin the read-back blockgetCurrentTexture()registered aTexture+ autoclear_viewin wgpu-core's hub every frame;CanvasGPUTexture::dropwas a no-op for surface texturestexture_drop(and discard-if-unpresented) in the surface branchOn-device result (foreground soak)
What it was NOT
global.gc()every 30 frames had no effect — the growth wasreachable native memory, not collectible JS.
the bytes were wgpu resources held in the hub.
Method
heapprofd growth analysis (per-callsite live-bytes delta, first vs last dump) +
llvm-addr2lineagainst an unstripped local build (LTO/ICF off so frames don't fold). Named the growers exactly:surface_get_current_texture(present.rs:220),device_create_command_encoder.