luaguides

Event Systems and Message Passing in Lua

Lua has no built-in event system. Games, GUIs, and network servers need one: when the player clicks, when a connection closes, when data arrives. Building event systems from scratch teaches you the patterns that libraries like lua-ev and copas wrap under the hood. This guide covers the patterns you need, from the simplest callback list to a full pub-sub system.

The callback list pattern

The simplest approach: store functions in a list and call them in order.

local callbacks = {}

local function on_click(handler)
  table.insert(callbacks, handler)
end

local function trigger_click(x, y)
  for _, handler in ipairs(callbacks) do
    handler(x, y)
  end
end

on_click(function(x, y)
  print("clicked at", x, y)
end)

trigger_click(100, 200)
-- clicked at   100   200

This works for straightforward cases. The downside: no way to remove a specific callback, and all handlers always fire. When your Lua program grows beyond a single script, say a game with dozens of UI widgets or a network daemon with per-connection state, you need named events so handlers register for only the signal they care about. Without per-event namespacing, every trigger_click call iterates every registered function, including ones meant for keypresses or network timeouts. The callback list also offers no unsubscribe mechanism, so once a handler is registered it stays active for the lifetime of the process, which leaks both memory and unwanted side effects in long-running Lua applications.

A simple event emitter

An event emitter stores handlers per event name. Removing a handler requires returning a function:

local EventEmitter = {}
EventEmitter.__index = EventEmitter

function EventEmitter.new()
  return setmetatable({ _handlers = {} }, EventEmitter)
end

function EventEmitter.on(self, event, handler)
  if not self._handlers[event] then
    self._handlers[event] = {}
  end
  table.insert(self._handlers[event], handler)

  -- return an unsubscribe function
  return function()
    local list = self._handlers[event]
    for i = #list, 1, -1 do
      if list[i] == handler then
        table.remove(list, i)
        break
      end
    end
  end
end

function EventEmitter.emit(self, event, ...)
  local list = self._handlers[event] or {}
  for _, handler in ipairs(list) do
    handler(...)
  end
end

The EventEmitter implementation above forms the backbone of most Lua event libraries. Each call to on() returns a closure that scans the handler list in reverse, a deliberate choice, since table.remove shifts array indices when deleting from the front, but iterating backward with for i = #list, 1, -1 keeps the loop indices stable regardless of removals. The emit method passes variadic arguments through with ..., so handlers receive exactly the data the emitter sends, whether a single integer or a table of collision parameters. The metatable pattern with __index = EventEmitter gives every instance shared method access without copying functions, which is the idiomatic Lua approach to lightweight OOP.

local emitter = EventEmitter.new()

local unsubscribe = emitter:on("player_damaged", function(player_id, damage)
  print("player", player_id, "took", damage, "damage")
end)

emitter:emit("player_damaged", 42, 25)
-- player   42   took   25   damage

unsubscribe()  -- stop listening
emitter:emit("player_damaged", 42, 10)  -- nothing happens

Publish-Subscribe

The pub-sub pattern takes the event emitter one step further by decoupling event names from object instances. Where an EventEmitter ties handlers to a specific emitter object, as in emitter:on("damage", fn), a pub-sub system uses globally accessible topic strings shared across the entire Lua runtime. This means a damage system in one module can publish "entity_destroyed" without ever importing the module that subscribes, which is essential for large Lua codebases where subsystems like audio, particle effects, and UI must all react to the same game event independently. The pub-sub table is a singleton: there is no constructor because all subscribers share the same _subscribers table reference, making it accessible from any require-d module.

local pubsub = {
  _subscribers = {}
}

function pubsub.subscribe(topic, handler)
  if not pubsub._subscribers[topic] then
    pubsub._subscribers[topic] = {}
  end
  table.insert(pubsub._subscribers[topic], handler)

  return function()
    local list = pubsub._subscribers[topic]
    for i = #list, 1, -1 do
      if list[i] == handler then
        table.remove(list, i)
        break
      end
    end
  end
end

function pubsub.publish(topic, ...)
  local list = pubsub._subscribers[topic] or {}
  for _, handler in ipairs(list) do
    handler(...)
  end
end

The subscribe method stores handlers in pubsub._subscribers[topic] using table.insert, and publish broadcasts to every handler in that topic’s list via ipairs. Each subscriber receives the same variadic arguments, so publish("entity_destroyed", entity) passes the full entity table to every listener. Calling the returned closure removes the handler from the list by iterating in reverse, the same table.remove-safe backward scan used by the EventEmitter, ensuring no handler is skipped during the unsubscribe traversal. In a LÖVE2D game, you would call subscribe during entity creation and publish from the update loop’s collision detection pass.

local unsubs = pubsub.subscribe("entity_destroyed", function(entity)
  print("entity destroyed:", entity.id)
end)

-- somewhere in your game loop
pubsub.publish("entity_destroyed", { id = 7, type = "enemy" })
-- entity destroyed:   7

The unsubscribe pattern is the same: calling the returned closure stops the handler from receiving future publishes. The key difference from the EventEmitter approach is a matter of coupling: with pub-sub, the publisher never holds a reference to any subscriber, and the subscriber never knows which module published the topic. This loose coupling makes pub-sub ideal for Lua projects like LÖVE2D games where a physics module, an audio module, and a particle system all respond to collision events without importing each other’s internals. No single module owns the event namespace; any require-d file can subscribe to or publish on any topic string.

Message Queues

For async or deferred processing, use a queue. Messages pile up and get processed on demand. While pub-sub fires handlers synchronously during publish, a queue decouples the timing: events accumulate during one phase of your program and are drained during another, giving you control over when side effects happen.

local Queue = {}
Queue.__index = Queue

function Queue.new()
  return setmetatable({ _messages = {} }, Queue)
end

function Queue.push(self, topic, ...)
  table.insert(self._messages, { topic = topic, args = {...} })
end

function Queue.process(self)
  while #self._messages > 0 do
    local msg = table.remove(self._messages, 1)
    local list = self._subscribers[msg.topic] or {}
    for _, handler in ipairs(list) do
      handler(unpack(msg.args))
    end
  end
end

function Queue.subscribe(self, topic, handler)
  if not self._subscribers[topic] then
    self._subscribers[topic] = {}
  end
  table.insert(self._subscribers[topic], handler)
end

The Queue class merges pub-sub semantics with deferred dispatch. Unlike the earlier patterns where emit or publish runs handlers synchronously in the call stack, Queue.push stores messages in insertion order via table.insert, and Queue.process drains them in FIFO order with table.remove(self._messages, 1). The _subscribers table on each queue instance is populated by Queue.subscribe, which mirrors the pub-sub interface but keeps subscriptions local to that queue. Deferred execution is critical in Lua game loops that use fixed-timestep updates: if an entity is destroyed mid-tick, any handler that tries to reference that entity’s table fields will hit a nil access, so you queue destruction events and process them between update passes.

local events = Queue.new()

-- called during update
events:push("collision", entity_a, entity_b)

-- called after all updates
events:process()

One-time events with promises

The message queue pattern works well for per-frame batch processing, but many Lua use-cases call for one-shot signalling: initialisation complete, asset loaded, timeout expired. For these, a queue introduces unnecessary overhead because you push a message only to process it immediately from the same call stack. The one-time event pattern discards the list-of-handlers approach entirely and instead stores a single handler per event name in a lightweight table. After emit_once fires, it sets the handler slot to nil with an explicit assignment, so a second call to emit_once with the same event name is a silent no-op. You will see this pattern in Lua module initialisers throughout the LÖVE2D and LÖVR ecosystems, where startup events like love.load complete exactly once.

local once = setmetatable({}, {
  __call = function(self, event, handler)
    self[event] = handler
  end
})

function emit_once(event, ...)
  local handler = once[event]
  if handler then
    once[event] = nil
    handler(...)
  end
end

once("initialized", function()
  print("init done")
end)

emit_once("initialized")  -- prints "init done"
emit_once("initialized")  -- nothing

The one-time handler above works when you fire-and-forget, but many Lua programs need the resolved value after the event fires. A promise-like table captures the fulfilled state so that calling then after resolution still receives the value; this is the _state == "fulfilled" check at the top of the then method. The implementation uses setmetatable with __index = Promise so each promise inherits the then method from the prototype table via metamethod dispatch. Unlike JavaScript promises, this Lua version does not chain: each then returns nil rather than a new promise, but it covers the common case of waiting for a single asynchronous result like a coroutine-completed signal or a network response callback from LuaSocket.

local Promise = {}
Promise.__index = Promise

function Promise.new()
  return setmetatable({
    _state = "pending",
    _value = nil,
    _handlers = {}
  }, Promise)
end

function Promise.resolve(value)
  local p = Promise.new()
  p._state = "fulfilled"
  p._value = value
  for _, h in ipairs(p._handlers) do h(value) end
  p._handlers = {}
  return p
end

function Promise.then(self, handler)
  if self._state == "fulfilled" then
    handler(self._value)
  else
    table.insert(self._handlers, handler)
  end
end

Using events with coroutines

The promise pattern is useful for single-value resolution, but it does not help when an event handler must pause mid-execution, for example an NPC behaviour tree that emits a wait(seconds) event and expects to resume after the timer expires. Lua coroutines provide exactly this capability through coroutine.yield and coroutine.resume. The scheduler below wraps coroutines in a cooperative multitasking loop: each scheduled function runs until it yields or completes, and suspended coroutines are re-queued for the next tick by checking coroutine.status. This is the same pattern used by the LÖVE2D love.thread module and by CoppeliaSim for Lua scripting within robot simulation loops, where each behaviour script runs in its own coroutine and yields every simulation step.

local scheduler = {
  _queue = {}
}

function scheduler.schedule(coroutine_func, ...)
  local co = coroutine.create(coroutine_func)
  table.insert(scheduler._queue, { co = co, args = {...} })
end

function scheduler.run()
  while #scheduler._queue > 0 do
    local task = table.remove(scheduler._queue, 1)
    local ok, err = coroutine.resume(task.co, unpack(task.args))
    if not ok then
      print("error:", err)
    elseif coroutine.status(task.co) == "suspended" then
      -- re-queue for next tick
      table.insert(scheduler._queue, task)
    end
  end
end

The scheduler code above implements a simple round-robin cooperative scheduler using coroutine.create and coroutine.resume. When coroutine.resume returns with status "suspended", checked via coroutine.status(task.co), the task goes back into the queue for the next call to scheduler.run(). Completed coroutines, whose status is "dead", are simply dropped from the queue. This pattern is the foundation of Lua-based game scripting in engines like Solar2D (formerly Corona SDK), where each scene or entity runs in its own coroutine that yields every frame. Error handling via coroutine.resume with the ok, err return pattern lets you catch exceptions without crashing the entire scheduler loop. The following example shows a handler that yields once and resumes on the next tick.

scheduler.schedule(function(player_id)
  print("waiting for player", player_id)
  coroutine.yield()  -- pause until next frame
  print("resumed after yield")
end)

scheduler.run()   -- first tick
scheduler.run()   -- second tick

OpenResty: request-level events

The coroutine scheduler is powerful for long-running Lua processes like game loops, but OpenResty operates in a different model: each HTTP request runs as a short-lived Lua VM instance inside Nginx worker processes, and coroutines that yield across request boundaries are not supported. Instead, OpenResty exposes ngx.ctx, a per-request Lua table that persists across the access, content, and log phases of a single request lifecycle. This per-request table is an event bus where earlier phases emit structured data (authentication results, rate-limit decisions, parsed headers) and later phases read that data without needing shared global state. The table is automatically cleared when the request completes, so there is no risk of memory leaks from stale handler references.

local function before_handler()
  ngx.ctx.events = {}
end

local function emit(event_name, data)
  ngx.ctx.events[event_name] = ngx.ctx.events[event_name] or {}
  table.insert(ngx.ctx.events[event_name], data)
end

local function on_event(event_name, handler)
  local list = ngx.ctx.events[event_name] or {}
  for _, data in ipairs(list) do
    handler(data)
  end
end

Call before_handler() in the access_by_lua_file phase to initialize the per-request event table, then emit events during the content_by_lua_file phase where you generate the response body, and handle them in log_by_lua_file for post-request processing like metrics collection or audit logging. Because ngx.ctx is request-scoped, you never need to worry about unsubscribe; the table evaporates when the request completes.

Gotchas

Every event pattern in Lua carries subtle failure modes that surface in production. Here are the ones that bite hardest in long-running applications, game loops, and web servers.

Memory leaks from forgotten listeners. Every on() call without a matching unsubscribe leaks memory in long-running applications. Always use the returned unsubscribe function.

Handlers firing after teardown. If you clean up event listeners after destroying an object, make sure no events can fire between cleanup and destruction.

Stack overflow from recursive emits. If handler A emits the same event that triggers handler B, which emits again, you get infinite recursion. Check or guard against re-entrant emits if your handlers call back into the event system.

Order of handlers matters. All handlers fire in registration order. If handler B depends on handler A running first, don’t let external code register B before A.

Choosing the right event pattern for your Lua project comes down to two questions: how long does your process live, and how tightly coupled are the modules that produce and consume events. Short-lived scripts can get away with a callback list; game loops benefit from message queues; OpenResty demands request-scoped tables. Beyond the patterns shown here, libraries like lua-ev (libev bindings) and copas (LuaSocket’s cooperative scheduler) provide production-grade event loops, but understanding the internals helps you debug when things go wrong. See the /guides/lua-debug-library/ guide for techniques to trace handler execution in complex event graphs.

See Also