How Coroutines Actually Work in Roblox Lua

Coroutines in Roblox are just Lua threads that you can pause and resume at will. Most people use them without really understanding why they work the way they do, which leads to broken code when things get non-trivial. Here's how to actually use them properly. In Roblox, Lua runs on a single thread per script. When you do something that blocks — like waiting for a server response or looping through a large dataset — your entire script freezes. Coroutines let you split that work across different execution contexts without spawning a whole new script. The standard library gives you coroutine.create(), coroutine.resume(), coroutine.yield(), and coroutine.wrap() to work with. I spent two days debugging a system where every enemy AI was freezing the client because someone used task.wait() inside a massive loop. Wrapping that loop body in a coroutine with a yield between iterations dropped the freeze from several seconds per frame to under 16 milliseconds. That's the practical value right there.

Creating and Running a Coroutine

The most basic pattern looks like this: local co = coroutine.create(function()\n print("started")\n coroutine.yield()\n print("resumed")\nend)\n\nprint("before resume")\ncoroutine.resume(co)\nprint("after first resume")\ncoroutine.resume(co)\nprint("done") The output is: "before resume", "started", "after first resume", "resumed", "done". The function pauses at coroutine.yield() and only continues when you call coroutine.resume() again. Each resume call picks up exactly where the last one left off.

The Wrap Method Is What You Should Use

coroutine.wrap() returns a function you can call directly instead of dealing with the create/resume dance. This is cleaner and less error-prone: local runOnce = coroutine.wrap(function()\n for i = 1, 10 do\n print(i)\n task.wait()\n end\nend)\n\nrunOnce()\nrunOnce() -- this won't restart, it continues from where it yielded One thing beginners miss: calling the wrapped function multiple times doesn't create multiple independent executions. It resumes the same paused coroutine. If you need parallel execution, call task.spawn() or create separate coroutines.

Get the Full Details

Coroutine - Roblox Tutorial
Coroutine - Roblox Tutorial

Pipeline Pattern for Async Chains

Here's a pattern I use constantly for loading or sequential async operations: local function pipeline(...)\n local co = coroutine.create(function()\n for _, fn in ipairs({...}) do\n local result = fn()\n coroutine.yield(result)\n end\n end)\n return co\nend\n\nlocal co = pipeline(\n function() return "step one done" end,\n function() return "step two done" end,\n function() return "step three done" end\n)\n\nlocal _, result = coroutine.resume(co)\nprint(result) -- "step one done" This lets you process each stage independently and handle errors or cancellations at any point.

Common Pitfalls That Will Waste Your Time

The biggest issue is mixing coroutines with Roblox's event system in ways that create silent deadlocks. If you yield inside an event callback and never resume, that coroutine is gone forever. The state object gets garbage collected, the variables leak, and you have no error message telling you what happened. I ran into this with a inventory system where a coroutine yielded while waiting for a remote event response, but the remote was never fired because the client disconnected mid-operation. The coroutine sat there indefinitely. My workaround was wrapping every yield in a pcall with a timeout check, and adding a cleanup function that resumes every active coroutine with an error code on disconnect: local activeCoros = {}\n\nlocal function safeResume(co, ...) activeCoros[co] = true local success, result = coroutine.resume(co, ...) if not success then warn("Coroutine error:", result) end activeCoros[co] = nil end game.Players.PlayerRemoving:Connect(function(player) for co in pairs(activeCoros) do coroutine.resume(co, "cancelled: player disconnect") end end)

That cleanup block alone fixed three different memory leak reports I was getting in production builds.

Bindtoclose or Coroutine is not working - Scripting Support - Developer Forum | Roblox
Bindtoclose or Coroutine is not working - Scripting Support - Developer Forum | Roblox

When Not to Use Coroutines

Coroutines are not a replacement for proper async patterns. If you're doing something that needs true parallelism across multiple independent tasks, task.spawn() is simpler and safer. Coroutines excel when you need deterministic control flow — when you need to pause, inspect state, and resume at a specific point. They also consume more memory per instance than a simple callback chain because each coroutine carries its own stack. Another limitation: you cannot yield across FFI boundaries or native Roblox C calls. If your coroutine yields while inside a call to something like HttpService or a data store operation, behavior becomes undefined. Always structure your yields around Lua-level operations, not around engine callbacks that might fire asynchronously.

A Real Production Example

Here's a chunk of code from a game I shipped that handles character respawning with coroutine-based delays: local respawnQueue = {}\n\nlocal function handleRespawn(player, delaySeconds)\n local co = coroutine.wrap(function()\n task.wait(delaySeconds)\n if player.Character then return end\n local char = Instance.new("Model")\n char.Name = "Character"\n char.Parent = workspace\n -- setup character here\n respawnQueue[player.UserId] = nil\n end)\n respawnQueue[player.UserId] = co\n co()\nend The queue table is the important part. It lets you cancel a pending respawn by calling respawnQueue[player.UserId] = nil before the coroutine runs. Without that cancellation path, players would respawn even after already reconnecting, creating duplicate characters and ghost entities that crashed other players' clients.

Debugging Tips

Use coroutine.status() to check if a coroutine is running, suspended, dead, or normal. It saves hours when you're trying to figure out why a function isn't executing. A "dead" status means the coroutine finished or errored out — check coroutine.resume()'s return value for the error message. Set debug.sethook() on your coroutine to trace execution if you're stuck on a yield that never seems to resolve. It's a heavy operation, so only use it during development.

Are coroutines useful in this scenario? - Scripting Support - Developer Forum | Roblox
Are coroutines useful in this scenario? - Scripting Support - Developer Forum | Roblox