Rive Runtime Bug Report: Metal Drawable Accumulation (Not Recycled)
Note: This is NOT a traditional memory leak (Instruments "Leaks" shows no leaks).
This is drawable pool exhaustion - drawables are retained but never returned to the pool for reuse.
Summary
The Rive iOS/macOS runtime accumulates Metal drawables (CAMetalDrawable) that are never released back to the drawable pool, causing GPU memory exhaustion and frame drops after extended runtime.
Environment
| Component |
Version |
| Platform |
macOS (Apple Silicon) |
| Rive Runtime |
6.15.1 (rive-ios) |
| Rive Commit |
388cebf |
| Xcode |
26.2 (Build 17C52) |
| macOS |
26.2 (Build 25C56) |
| Renderer |
Rive Renderer (experimental, @_spi(RiveExperimental)) |
Symptoms
- Application runs smoothly initially
- After several hours of runtime, frame rate gradually degrades
- Eventually animation becomes extremely choppy (dropping most frames)
- Pause/unpause does NOT fix the issue
- Waiting 20+ seconds (longer than 8s auto-trim interval) does NOT fix the issue
- Only full application restart resolves the issue
Evidence
Memory Analysis (vmmap)
After ~15 hours of runtime:
IOSurface 2.8G 2.8G 2.8G 0K 0K 2.8G 0K 81
TOTAL 6.8G 4.4G 4.0G 16K 0K 2.8G 0K 6768
2.8 GB of IOSurface memory accumulated (81 IOSurface allocations)
Instruments Profiling (Allocations - Call Tree)
The Instruments Allocations tool with Call Tree view shows the complete call path from app entry to IOSurface creation:
Bytes Used % Count Symbol Library
──────────────────────────────────────────────────────────────────────────────────────
644.95 MB 100.0% 62111 __debug_main_executable_dylib_entry_point RWPP
644.95 MB 100.0% 62111 └─ static App.main() SwiftUI
643.70 MB 99.8% 57061 └─ NSApplicationMain AppKit
643.75 MB 99.8% 57622 └─ -[NSApplication run] AppKit
... (event loop)
626.78 MB 97.1% 31416 └─ -[MTKView draw] MetalKit
626.78 MB 97.1% 31414 └─ @objc RiveView.draw(_:) RiveRuntime ← RIVE
626.78 MB 97.1% 31410 └─ -[RiveRendererView drawRect:] RiveRuntime ← RIVE
626.77 MB 97.1% 31407 └─ -[RiveRendererView drawInRect:withCompletion:] RiveRuntime ← RIVE
606.00 MB 93.9% 715 └─ -[MTKView currentDrawable] MetalKit
606.00 MB 93.9% 715 └─ -[CAMetalLayer nextDrawable] QuartzCore
605.87 MB 93.9% 656 └─ CAMetalLayerPrivateNextDrawableLocked QuartzCore
605.83 MB 93.9% 95 └─ get_unused_drawable QuartzCore
605.81 MB 93.9% 17 └─ CA::Render::create_iosurface_with_pixel_format QuartzCore
605.81 MB 93.9% 13 └─ CA::SurfaceUtil::CAIOSurfaceCreate QuartzCore
605.81 MB 93.9% 13 └─ IOSurfaceCreate IOSurface
605.81 MB 93.9% 9 └─ -[IOSurface initWithProperties:] IOSurface
605.81 MB 93.9% 9 └─ IOSurfaceClientCreateChild IOSurface
The bug is in RiveRendererView's drawInRect:withCompletion: method - it obtains drawables via currentDrawable but they are never returned to the pool.
Key Finding
| Metric |
Expected |
Actual |
| Drawables created |
2-3 (triple buffering) |
715 |
| IOSurface memory |
~2.5 MB (3 × ~850KB) |
2.8 GB |
| Drawable recycling |
Yes |
No |
715 Metal drawables were created but NONE were recycled back to the pool.
No Traditional Leaks Detected
Instruments "Leaks" instrument shows green checkmarks (no leaks detected). This confirms:
- The drawables are still referenced (not orphaned pointers)
- They are simply never released back to the drawable pool
- This is a drawable lifecycle management issue, not a missing
free()
Root Cause Analysis
Based on the call stack, the bug is in RiveRendererView (RiveRuntime):
Call Chain
-[MTKView draw] // MetalKit calls this each frame
└─ @objc RiveView.draw(_:) // Rive's RiveView
└─ -[RiveRendererView drawRect:] // Rive's renderer view
└─ -[RiveRendererView drawInRect:withCompletion:] // ← BUG IS HERE
└─ -[MTKView currentDrawable] // Gets drawable (715 times!)
What's Happening
- Each frame,
RiveRendererView drawInRect:withCompletion: calls currentDrawable
- The drawable is used for rendering
- After rendering, the drawable is NOT returned to the CAMetalLayer's drawable pool
- On the next frame,
nextDrawable finds no available drawables in the pool
get_unused_drawable creates a NEW drawable backed by a new IOSurface
- This repeats every frame, accumulating drawables indefinitely
Likely Bug Location
The bug is likely in one of these Rive files:
RiveRendererView.mm or RiveRendererView.swift
- Specifically in the
drawInRect:withCompletion: method
- Or in how the completion handler manages the drawable
Possible Causes
- Missing
presentDrawable: - The drawable may not be presented to the display
- Command buffer not committed - The MTLCommandBuffer may not be committed properly
- Drawable retained in completion block - The completion handler may be holding the drawable
- Strong reference cycle -
withCompletion: block may capture the drawable strongly
- Drawable stored but never released - Some internal tracking may retain drawables
Expected Metal Drawable Lifecycle
1. drawable = [metalLayer nextDrawable] // Get from pool (or create if empty)
2. texture = drawable.texture // Use for rendering
3. [commandBuffer presentDrawable:drawable] // Schedule presentation
4. [commandBuffer commit] // Submit to GPU
5. // On completion: drawable automatically returns to pool
6. // Next frame: step 1 reuses the same drawable
Actual Behavior (Bug)
1. drawable = [metalLayer nextDrawable] // Pool empty, creates new
2. texture = drawable.texture // Use for rendering
3. [commandBuffer presentDrawable:drawable] // Maybe not called?
4. [commandBuffer commit] // Maybe not called?
5. // Drawable NEVER returns to pool
6. // Next frame: pool still empty, creates ANOTHER new drawable
7. // Repeat 715+ times until GPU memory exhausted
Reproduction Steps
- Create an app using RiveRuntime with
@_spi(RiveExperimental) renderer
- Load a Rive animation (especially one with Luau scripting that draws paths)
- Let it run continuously for several hours
- Monitor memory:
watch -n 60 'vmmap -summary $(pgrep AppName) | grep IOSurface'
- Observe IOSurface memory growing continuously (~1-2 MB per minute)
- After several hours, animation becomes choppy due to drawable starvation
Workaround
The only effective workaround is to periodically destroy and recreate all RiveView instances:
// 1. Capture screenshot for visual continuity
let screenshot = captureWindow()
// 2. Destroy all Rive objects
riveView.removeFromSuperview()
riveViewModel = nil
riveView = nil
// 3. Wait for deallocation (releases all drawables)
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
// 4. Recreate views
riveView = RiveView(...)
riveViewModel = RiveViewModel(...)
}
This forces deallocation of the CAMetalLayer, which releases all accumulated drawables.
Impact
- Severity: High
- User Impact: Application becomes unusable after extended runtime
- Use Case Affected: Any long-running Rive animation (kiosk displays, wallpapers, dashboards)
Additional Notes
- The issue does NOT appear to be related to Luau scripting specifically
- The issue is in the core Metal rendering pipeline
- RenderContext's 8-second auto-trim does not affect drawable pool
releaseResources() is only called on RenderContext dealloc, not periodically

Rive Runtime Bug Report: Metal Drawable Accumulation (Not Recycled)
Summary
The Rive iOS/macOS runtime accumulates Metal drawables (CAMetalDrawable) that are never released back to the drawable pool, causing GPU memory exhaustion and frame drops after extended runtime.
Environment
@_spi(RiveExperimental))Symptoms
Evidence
Memory Analysis (vmmap)
After ~15 hours of runtime:
2.8 GB of IOSurface memory accumulated (81 IOSurface allocations)
Instruments Profiling (Allocations - Call Tree)
The Instruments Allocations tool with Call Tree view shows the complete call path from app entry to IOSurface creation:
The bug is in
RiveRendererView'sdrawInRect:withCompletion:method - it obtains drawables viacurrentDrawablebut they are never returned to the pool.Key Finding
715 Metal drawables were created but NONE were recycled back to the pool.
No Traditional Leaks Detected
Instruments "Leaks" instrument shows green checkmarks (no leaks detected). This confirms:
free()Root Cause Analysis
Based on the call stack, the bug is in
RiveRendererView(RiveRuntime):Call Chain
What's Happening
RiveRendererView drawInRect:withCompletion:callscurrentDrawablenextDrawablefinds no available drawables in the poolget_unused_drawablecreates a NEW drawable backed by a new IOSurfaceLikely Bug Location
The bug is likely in one of these Rive files:
RiveRendererView.mmorRiveRendererView.swiftdrawInRect:withCompletion:methodPossible Causes
presentDrawable:- The drawable may not be presented to the displaywithCompletion:block may capture the drawable stronglyExpected Metal Drawable Lifecycle
Actual Behavior (Bug)
Reproduction Steps
@_spi(RiveExperimental)rendererwatch -n 60 'vmmap -summary $(pgrep AppName) | grep IOSurface'Workaround
The only effective workaround is to periodically destroy and recreate all RiveView instances:
This forces deallocation of the CAMetalLayer, which releases all accumulated drawables.
Impact
Additional Notes
releaseResources()is only called on RenderContext dealloc, not periodically