Diagnosing Lua Garbage Collector Latency Spikes in Real‑Time Applications
Identify and fix Lua GC‑induced latency spikes in real‑time loops by measuring per‑step costs, tuning step multiplier and pause ratio, or switching to manual stepping.
03 Aug 2026, 13:30 UTC

Recognizable Condition
In a real‑time Lua loop (e.g., a game render loop or audio callback) you observe periodic frame‑time spikes that exceed your latency budget (for example, >2 ms per frame). Profiling with os.clock() or a CPU profiler shows that the spikes line up with moments when the Lua garbage collector runs.
Cause & Diagnostic Factors
| Factor | Effect on GC Pause |
|---|---|
Step multiplier (LUA_GCSETSTEPMUL) | Larger values cause the collector to do more work per incremental step, lengthening each pause. |
| Heap size | A larger heap means each incremental step processes more bytes, increasing pause time. |
| Allocation rate | Frequent allocations (temporary tables, strings) push the heap toward the threshold that triggers a full GC cycle, which can cause the longest pauses. |
GC pause ratio (LUA_GCSETPAUSE) | Lower values make the collector start incremental steps sooner, reducing the chance of a large pause but increasing total CPU overhead. |
Ordered Diagnostic Checks
- Enable per‑step GC logging – After each allocation that you suspect, call
lua_gc(L, LUA_GCSTEP, 0)and log the returned GC count. A sudden jump in the count indicates that a step (or full cycle) just ran. - Measure time around GC calls – Wrap the allocation region with high‑resolution timers:
Record the delta; compare it to your latency budget.local t0 = os.clock() -- allocation burst local t1 = os.clock() lua_gc(L, LUA_GCSTEP, 0) -- force a step and see its cost local t2 = os.clock() print('alloc time:', t1-t0, 'gc step time:', t2-t1) - Correlate spikes with allocation bursts – Instrument known hot spots (e.g., table creation for particles, string concatenation for UI) and note whether a spike follows each burst.
- Query current GC parameters – Run:
These values give you the baseline to adjust.print('step mul:', lua_gc(L, LUA_GCGETSTEPMUL)) print('pause:', lua_gc(L, LUA_GCGETPAUSE)) print('heap K:', lua_gc(L, LUA_GCCOUNT)) - Review allocation patterns – Search for avoidable temporaries:
{}inside loops,string.formatthat creates new strings, ortable.concaton small tables. Replace with reuse or pre‑allocated buffers where possible.
Applying Fixes
Reduce incremental step size
If the measured GC step time is too high, lower the step multiplier:
lua_gc(L, LUA_GCSETSTEPMUL, 50) -- example: reduced from 200
This makes each step do less work, shortening pauses at the cost of more steps per frame.
Increase GC pause ratio to favor incremental steps
When full GC cycles dominate, raise the pause ratio so the collector starts incremental work earlier:
lua_gc(L, LUA_GCSETPAUSE, 150) -- example: increased from 100
More frequent, smaller steps reduce the chance of a long pause.
Manual stepping for deterministic budgets
Disable automatic GC and drive it yourself each frame:
lua_gc(L, LUA_GCSTOP, 0) -- stop automatic collector
-- inside your fixed‑time step loop:
local steps = 100 -- tune based on measured step cost
lua_gc(L, LUA_GCSTEP, steps)
Adjust steps until the heap stabilizes (see verification).
Lower allocation pressure
Replace temporary tables with object pools or reuse a single table cleared via table.clear (Lua 5.4) or setting its numeric indices to nil. For strings, consider using luaL_Buffer or caching formatted results.
Escalation Criteria
If after tuning the GC parameters and reducing allocations the frame‑time spikes still exceed your budget:
- Consider switching to LuaJIT, which uses a different, generational GC with typically lower pause times.
- Evaluate a custom allocator (e.g., using
ffito manage raw memory) for hot‑path data. - Redesign the hot path to eliminate allocations entirely: pre‑allocate fixed‑size buffers, use light userdata for C‑side data, or shift work to a C extension.
Verification Steps
- Test script – Create a script that allocates a known number of tables per frame, measures time before and after each
lua_gc(L, LUA_GCSTEP, 0)call, and logs the delta. Run it with the original GC settings, then after changingLUA_GCSETSTEPMULorLUA_GCSETPAUSE. Verify that the observed delta changes proportionally. - Sustained run – Execute a loop of, e.g., 10 000 frames, timing each frame with
os.clock()orsocket.gettime. Confirm that spikes above the threshold disappear or fall within acceptable limits. - Heap sanity check – Before and after the test, query
lua_gc(L, LUA_GCCOUNT). Ensure the heap is not growing unboundedly when you have disabled automatic GC or altered step size.
Note: Changing GC step multiplier or pause values can increase overall memory usage; monitor heap growth to avoid out‑of‑memory conditions. Disabling the automatic GC requires diligent manual stepping; insufficient steps will cause memory accumulation and eventual crashes.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.