Fixing LÖVE Stutter: Solving Garbage Collection Spikes and Draw Call Overhead
Diagnose and fix LÖVE frame drops caused by Lua garbage collection spikes and excessive draw calls, using object pooling, SpriteBatch, and built-in profiling.
18 Feb 2026, 08:43 UTC

When a LÖVE (Love2D) game runs smoothly for several minutes then suddenly stutters, the culprit is rarely the GPU's raw power. Instead, the bottleneck is usually the Lua garbage collector (GC) struggling to keep up with temporary objects, or the CPU being overwhelmed by thousands of individual draw commands. To maintain a consistent 60 FPS, shift from per-frame allocation to object pooling and batch your rendering.
Diagnosing the Bottleneck
Before refactoring code, identify whether your performance drop is memory-bound or CPU-bound. Use this table to map observed symptoms to technical causes:
| Symptom | Likely Cause | Primary Metric |
|---|---|---|
| Micro-stutters every few seconds | High-frequency table creation in love.update | Lua GC frequency |
| Low FPS despite low GPU usage | Excessive individual draw calls (CPU-to-GPU bottleneck) | Frame time per loop |
| Gradual slowdown over long play | Memory leaks (global table accumulation) | Heap size over time |
| Stuttering during new area loads | VRAM exhaustion / texture swapping | Video memory usage |
Step 1: Profiling the Frame Budget
LÖVE provides a built-in FPS and frame-time display. Enable it in love.load:
function love.load()
love.timer.toggle(true) -- Shows FPS and frame time on screen
endIf frame time spikes occasionally while the average stays low, you are likely hitting a GC collection. If frame time is consistently high, you likely have too many draw calls or heavy per-frame logic. These snippets run inside your LÖVE project (Lua 5.1 / LuaJIT, LÖVE 11.x assumed); no special permissions are needed.
Step 2: Eliminating Garbage Collection Spikes
The most common cause of stuttering in Lua is creating tables inside love.update or love.draw. Every table like {x = 10, y = 20} created per frame must eventually be reclaimed by the GC, and each collection pause can drop a frame. Lua's GC is non-deterministic, so these pauses feel random.
Bad pattern: allocation per frame
function love.update(dt)
for i = 1, 100 do
-- Creates 100 new tables every single frame
particles[i] = { x = math.random(), y = math.random() }
end
endGood pattern: object pooling
Pre-allocate tables once and update their existing values:
function love.load()
particles = {}
for i = 1, 100 do
particles[i] = { x = 0, y = 0 }
end
end
function love.update(dt)
for i = 1, 100 do
-- Update existing memory, no new allocations
particles[i].x = particles[i].x + dt
particles[i].y = particles[i].y + dt
end
endTo confirm allocation pressure, monitor heap size with collectgarbage('count'), which returns memory used by Lua in kilobytes. Print it once per second; if it climbs steadily during gameplay, you are allocating (or leaking) in the loop. Avoid calling collectgarbage() manually every frame to compensate — a full collection causes its own visible stutter.
Step 3: Reducing Draw Calls with SpriteBatch
Calling love.graphics.draw 2,000 times per frame sends 2,000 separate commands from the CPU to the GPU. This saturates the CPU-to-GPU bridge even when the GPU itself is underutilized. A SpriteBatch groups these into a single GPU operation.
Constraint: all sprites in one batch must share the same texture (a texture atlas). Switching textures mid-batch breaks the optimization, so use one batch per texture.
function love.load()
image = love.graphics.newImage('tree.png')
batch = love.graphics.newSpriteBatch(image, 1000)
for i = 1, 1000 do
batch:add(math.random(0, 800), math.random(0, 600))
end
end
function love.draw()
love.graphics.draw(batch) -- One draw call for 1000 sprites
endTo verify the improvement, count draw calls by proxying love.graphics.draw or by comparing frame time before and after batching with love.timer. If frame time drops meaningfully with identical visuals, the batch is working.
Step 4: Checking for Memory Leaks and VRAM Pressure
A gradual RAM increase over a long session usually means tables are being stored in global scope or captured in closures without being cleared. Audit globals periodically and set unused references to nil. For texture-heavy games, large unscaled images can exhaust VRAM; stuttering when entering new areas is the telltale sign. Downscale textures to the size actually displayed and reuse loaded images instead of calling love.graphics.newImage repeatedly.
Escalation Criteria
If pooling and batching do not restore a stable frame time, the problem is likely elsewhere: profile physics (love.physics world updates), audio streaming, or shader complexity. At that point, capture a per-function profile with a Lua profiler or by wrapping suspect functions with love.timer.getTime() deltas, and optimize only the functions that measurably exceed your 16.6ms budget.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.