luaguides

Memory Optimization Techniques

Lua manages memory automatically through its garbage collector, which means you rarely think about memory allocation in the same way you would in C. But GC is not magic — it has costs, and certain patterns create memory pressure that slows your program or causes it to consume far more RAM than it needs.

This guide focuses on reducing actual memory usage, not just tuning the GC. You will learn how to measure memory consumption, find the leaks that silently grow resident memory, and apply concrete techniques like object pooling that cut allocation rates dramatically.

Measuring Memory Usage

Before you can optimise memory, you need to read it. collectgarbage("count") returns the current memory use in kilobytes:

local mem = collectgarbage("count")
print(("Memory in use: %.2f KB"):format(mem))

For more detail, call a full collection first and then read the count:

collectgarbage("collect")
local mem = collectgarbage("count")
print(("Memory after GC: %.2f KB"):format(mem))

In long-running processes — a game server, an OpenResty worker — plot memory usage over time. A steady upward slope between GC cycles is a leak. Flat memory with regular GC spikes is normal. Memory that climbs and then drops sharply is the collector catching up, which is fine as long as the floor does not rise over hours.

Watching for Allocations in Hot Loops

If you suspect a hot loop is creating too many short-lived objects, time a GC cycle before and after:

collectgarbage("collect")
local mem_before = collectgarbage("count")

for i = 1, 100000 do
  local _ = data[i].x + data[i].y  -- access fields to keep them reachable
end

collectgarbage("collect")
local mem_after = collectgarbage("count")
print(("Leaked during loop: %.2f KB"):format(mem_after - mem_before))

If memory grows substantially even after a full collection, the loop is leaking references — objects are becoming reachable when they should not be.

Common Memory Leaks

Lua leaks are subtler than C leaks. The GC will eventually collect everything unreachable, so a true leak only happens when references remain in places that never go away: global variables, registry entries, or closures that capture objects indefinitely.

Accidental Global References

The most common Lua memory leak is leaving a reference in _G:

function processBatch(items)
  local results = {}
  for i = 1, #items do
    results[i] = heavyTransform(items[i])
  end
  _G.lastResults = results  -- lives in _G until you overwrite it
  return results
end

_G.lastResults persists until the next call overwrites it. If processBatch is called infrequently, this is mostly harmless. If it runs every frame, you have a table per frame piling up in global scope.

Fix: keep temporary data in local scope or explicitly nil it when done:

function processBatch(items)
  local results = {}
  for i = 1, #items do
    results[i] = heavyTransform(items[i])
  end
  -- use results, then let it go out of scope
  return results
end

Closures Capturing Large Upvalues

When a closure captures an upvalue, that upvalue stays reachable as long as the closure is reachable. This is fine when you need the captured value. It becomes a leak when the closure outlives the data it captured:

function createHandler(largeData)
  -- This closure captures largeData
  return function(event)
    return "handled"
  end
end

local handler = createHandler(loadLargeDataset())
-- largeData is now trapped in the closure, even though the handler never uses it

Nil out the data after creating the closure if the closure does not need it:

function createHandler(largeData)
  local result = function(event)
    return "handled"
  end
  largeData = nil  -- free the reference now
  return result
end

Reference Cycles

Lua’s GC handles reference cycles correctly — two tables pointing at each other will both be collected when neither is reachable from outside. But if a cycle is reachable from a long-lived root, the entire cycle stays alive:

-- Each node references the next; the tail references the head
local head = {value = 0}
local current = head
for i = 1, 1000 do
  current.next = {value = i, prev = current}
  current = current.next
end
current.next = head  -- creates a cycle

-- If 'head' is stored in a global, the entire 1001-node cycle lives forever
_G.head = head

The fix: break the cycle explicitly before the table goes out of scope, or use weak tables (covered later) so the cycle does not prevent collection.

Object Pooling

Allocating and collecting objects is not free. Every table created in a hot loop is an allocation the GC must eventually collect. An object pool reuses a fixed set of objects instead of constantly creating new ones.

This pattern is especially useful in games where you spawn and destroy thousands of entities per second:

local projectilePool = {}
local poolSize = 0
local POOL_MAX = 1000

local function getProjectile()
  if poolSize > 0 then
    local p = projectilePool[poolSize]
    projectilePool[poolSize] = nil
    poolSize = poolSize - 1
    return p
  end
  return {}  -- pool empty, allocate normally
end

local function releaseProjectile(p)
  if poolSize < POOL_MAX then
    -- Reset the projectile's state before returning it to the pool
    p.x, p.y, p.vx, p.vy = nil, nil, nil, nil
    projectilePool[poolSize + 1] = p
    poolSize = poolSize + 1
  end
  -- If pool is full, the projectile is simply discarded (GC will claim it)
end

The key is resetting object state on release — otherwise you get bugs where a “new” projectile still has the position and velocity of its previous life.

Reusing Temporary Tables

The same idea applies to short-lived tables used as scratch space:

-- Naive: new table per iteration
for row in rows do
  local t = {row.x, row.y, compute(row.z)}
  send(t)
end

-- Better: reuse one table
local t = {}
for row in rows do
  t[1], t[2], t[3] = row.x, row.y, compute(row.z)
  send(t)
end

This eliminates thousands of allocation/free cycles. In a loop processing 100,000 rows, the naive version creates 100,000 tables; the reusable version creates one.

String Memory

Lua interns strings — it stores only one copy of each unique string value. Comparing two strings is a pointer comparison rather than a character-by-character check. This is fast and saves memory when you have many identical strings.

However, concatenating strings creates new strings. Long strings built piece by piece consume memory proportional to the total size of all pieces, even after concatenation:

local big = string.rep("x", 1000000)
local part1 = string.sub(big, 1, 500000)
local part2 = string.sub(big, 500001, 1000000)
-- Both substrings are new copies, not views into the original

If you are working with large string data, prefer to pass substrings as start/end indices rather than copying. If you must concatenate large strings, use table.concat on a pre-built table of parts rather than repeated ...

Weak Tables for Caches

A cache that grows without bound is a memory leak. Weak tables let you build caches that do not prevent the GC from collecting entries:

local cache = setmetatable({}, { __mode = "v" })

local function getCached(key, computeFn)
  if cache[key] ~= nil then
    return cache[key]
  end
  local value = computeFn(key)
  cache[key] = value
  return value
end

Because the cache has weak values (__mode = "v"), the GC can collect any cached value when memory pressure increases. The next call recomputes it. This approach is useful for memoization of expensive pure functions, caching parsed data, and storing results of file reads.

For caches keyed by multiple values, use weak keys (__mode = "k") or both (__mode = "kv"). Be careful with __mode = "kv" — if both key and value are collected, the entry disappears silently.

Memory in Specific Contexts

Game Development (LÖVE2D)

Game loops allocate constantly: particle systems, physics objects, UI elements. Profile during actual gameplay, not just startup. Object pooling for frequently-spawned entities (bullets, particles) makes the biggest difference. Also consider pre-allocating particle arrays rather than using tables for each particle.

OpenResty

In OpenResty, each Nginx worker is a long-lived Lua VM. A memory leak in a handler compounds over every request the worker handles. Set lua_max_running_frees (LuaJIT) and monitor worker memory with ps aux. Use weak tables for request-scoped caches. Avoid storing per-request data in module-level globals.

Embedded Systems

On constrained hardware, aggressive GC settings keep memory low:

collectgarbage("setpause", 100)   -- collect frequently
collectgarbage("setstepmul", 300) -- do more work per step
collectgarbage("collect")         -- then force a collection

Also prefer smaller data structures. A table of 1000 integers in Lua costs far more than a flat C array would. If memory is extremely tight, consider Lua’s binary data modules or writing performance-critical code in C.

See Also