Runner — Lightweight cancellable task manager for Roblox
A small Luau utility for managing async work with latest-only named jobs, group cancellation, soft or hard cancel behavior, cancel-aware waiting helpers, guarded callbacks, and automatic cleanup of internal references.
This version is more capable than the older one because tasks now get a richer context object, and cancellation can be either cooperative or immediate.
It fits UI flows, timed effects, NPC and state logic, loading pipelines, and any place where you want start work, replace old work, cancel safely, and keep cleanup predictable.
Module code
--!strict
local Runner = {}
Runner.tasks = {}
export type JobGroup = { thread }
export type BasicCtx = {
cancel: () -> (),
cancelled: () -> boolean,
sleep: (seconds: number) -> boolean,
waitSignal: (signal: RBXScriptSignal) -> (boolean, ...any),
guard: <A..., R...>(fn: (A...) -> R...) -> ((A...) -> R...?),
}
type Meta = {
name: string?,
group: JobGroup?,
oncancel: (() -> ())?,
cancelled: boolean,
thread: thread,
}
local threadMeta: { [thread]: Meta } = setmetatable({}, { __mode = "k" })
local function removeFromGroup(group: JobGroup?, th: thread)
if not group then
return
end
for i = #group, 1, -1 do
if group[i] == th then
table.remove(group, i)
return
end
end
end
local function cleanup(th: thread)
local meta = threadMeta[th]
if not meta then
return
end
if meta.name and Runner.tasks[meta.name] == th then
Runner.tasks[meta.name] = nil
end
removeFromGroup(meta.group, th)
threadMeta[th] = nil
end
local function isCancelled(th: thread): boolean
local meta = threadMeta[th]
if not meta then
return true
end
return meta.cancelled
end
local function cancelThread(th: thread, hardCancel: boolean?): boolean
local meta = threadMeta[th]
if not meta then
return false
end
if meta.cancelled then
return true
end
meta.cancelled = true
if meta.oncancel then
local ok, err = xpcall(meta.oncancel, debug.traceback)
if not ok then
warn("Runner oncancel error:", err)
end
end
if hardCancel ~= false then
local ok, err = pcall(task.cancel, th)
if not ok then
warn("Runner cancel error:", err)
end
cleanup(th)
end
return true
end
local function makeCtx(th: thread): BasicCtx
local function cancelled(): boolean
return isCancelled(th)
end
local function cancel()
cancelThread(th, true)
end
local function sleep(seconds: number): boolean
if cancelled() then
return false
end
local remaining = seconds
local step = 0.05
while remaining > 0 do
task.wait(math.min(step, remaining))
if cancelled() then
return false
end
remaining -= step
end
return not cancelled()
end
local function waitSignal(signal: RBXScriptSignal): (boolean, ...any)
if cancelled() then
return false
end
local done = false
local packed: { any } = table.create(8)
local conn: RBXScriptConnection? = nil
conn = signal:Connect(function(...)
done = true
packed = table.pack(...)
if conn then
conn:Disconnect()
conn = nil
end
end)
while not done do
if cancelled() then
if conn then
conn:Disconnect()
conn = nil
end
return false
end
task.wait()
end
if cancelled() then
return false
end
return true, table.unpack(packed, 1, packed.n)
end
local function guard<A..., R...>(fn: (A...) -> R...): ((A...) -> R...?)
return function(...: A...)
if cancelled() then
return nil
end
return fn(...)
end
end
return {
cancel = cancel,
cancelled = cancelled,
sleep = sleep,
waitSignal = waitSignal,
guard = guard,
}
end
local function spawnThread(
name: string?,
fn: (BasicCtx) -> (),
group: JobGroup?,
oncancel: (() -> ())?
)
local th = coroutine.create(function()
local running = coroutine.running()
if not running then
return
end
local ctx = makeCtx(running)
local ok, err = xpcall(function()
fn(ctx)
end, debug.traceback)
if not ok and not ctx.cancelled() then
warn("Runner thread error:", err)
end
cleanup(running)
end)
threadMeta[th] = {
name = name,
group = group,
oncancel = oncancel,
cancelled = false,
thread = th,
}
if name then
Runner.tasks[name] = th
end
if group then
table.insert(group, th)
end
task.spawn(th)
return function(hardCancel: boolean?)
cancelThread(th, hardCancel)
end
end
function Runner.start(
fn: (BasicCtx) -> (),
group: JobGroup?,
oncancel: (() -> ())?
)
return spawnThread(nil, fn, group, oncancel)
end
function Runner.startLatest(
name: string,
fn: (BasicCtx) -> (),
group: JobGroup?,
oncancel: (() -> ())?
)
local existing = Runner.tasks[name]
if existing then
cancelThread(existing, true)
end
return spawnThread(name, fn, group, oncancel)
end
function Runner.cancel(name: string, hardCancel: boolean?): boolean
local th = Runner.tasks[name]
if not th then
return false
end
return cancelThread(th, hardCancel)
end
function Runner.cancelGroup(group: JobGroup, hardCancel: boolean?)
for i = #group, 1, -1 do
local th = group[i]
group[i] = nil
if th then
cancelThread(th, hardCancel)
end
end
end
function Runner.cancelAll(hardCancel: boolean?)
for th in pairs(threadMeta) do
cancelThread(th, hardCancel)
end
end
return Runner
Why this exists
A common Roblox problem is starting async work that becomes invalid before it finishes.
Examples:
- a player switches tabs before the previous tab animation ends
- a hover or click animation chain should stop when a new one starts
- an NPC behavior should stop waiting when the target changes
- a timed effect should refresh instead of stacking old timers
- code is waiting for a signal, but that wait should stop if the job gets cancelled
Using raw task.spawn, task.wait, RBXScriptSignal:Wait(), and scattered booleans can work, but it gets messy:
- old jobs are easy to forget
- waits are hard to cancel cleanly
- signal waits can hang logic longer than desired
- cleanup logic gets duplicated
- replacing old jobs becomes repetitive
- callback code can still fire after the job is no longer relevant
Runner wraps those patterns into one small module.
What’s new in this version
Richer task context
Each task now gets:
ctx.cancel()ctx.cancelled()ctx.sleep(seconds)ctx.waitSignal(signal)ctx.guard(fn)
Soft cancel vs hard cancel
Cancellation now supports two modes:
- hard cancel = mark cancelled, run
oncancel, then calltask.cancel(thread) - soft cancel = mark cancelled, run
oncancel, but do not force-stop the thread
Soft cancel is useful when you want the running function to exit cooperatively using
ctx.cancelled(),ctx.sleep(), orctx.waitSignal().
Cancel-aware waiting
Instead of plain task.wait() or signal:Wait(), you can now use:
ctx.sleep(seconds)which returnsfalseif cancelled before completionctx.waitSignal(signal)which returnsfalseif cancelled before the signal fires
This makes async flows much easier to stop cleanly.
Guarded callbacks
ctx.guard(fn) wraps a callback so that it does nothing if the task has already been cancelled.
This is useful when passing callbacks into tweens, signals, delayed functions, or other async systems.
Features
Start a task and cancel it later
Every started task returns a cancel function.
Start latest-only named tasks
Starting a new task with the same name cancels the previous one first.
Group related tasks
You can place tasks into a shared group table and cancel them all together.
Optional oncancel callback
A task can define cleanup that runs as soon as the task is cancelled.
Examples include:
- stopping tweens
- disconnecting temp visuals
- restoring state
- clearing UI
- resetting values
Cooperative async helpers
Tasks can safely stop during waits with
ctx.sleepandctx.waitSignal.
Callback protection with guard
Wrapped callbacks automatically become no-ops after cancellation.
Automatic metadata cleanup
Finished or cancelled jobs are removed from internal tracking tables automatically.
API
Runner.start(fn, group?, oncancel?)
Starts an anonymous task.
Parameters
fn: function that receivesctxgroup: optional task group tableoncancel: optional cleanup callback that runs when cancelled
Returns
cancel:(hardCancel?: boolean) -> ()
That returned function cancels that specific task.
If
hardCancelis omitted, it defaults to hard cancel.
Runner.startLatest(name, fn, group?, oncancel?)
Starts a named task.
If another task already exists under the same name, the old one is hard-cancelled first.
Parameters
name: unique task keyfn: function that receivesctxgroup: optional group tableoncancel: optional cleanup callback
Returns
cancel:(hardCancel?: boolean) -> ()
Runner.cancel(name, hardCancel?)
Cancels the current named task for
name.
Parameters
-
name: job key -
hardCancel: optional booleantrueor omitted means hard cancelfalsemeans soft cancel
Returns
trueif a named task existedfalseif no named task was found
Runner.cancelGroup(group, hardCancel?)
Cancels all tasks inside a group.
Parameters
-
group: aJobGroup -
hardCancel: optional booleantrueor omitted means hard cancelfalsemeans soft cancel
Runner.cancelAll(hardCancel?)
Cancels all tracked tasks, not just named ones.
This is different from the older version.
Parameters
-
hardCancel: optional booleantrueor omitted means hard cancelfalsemeans soft cancel
Context API
Every task function receives a
ctx: BasicCtx.
ctx.cancelled() -> boolean
Returns whether this task has been cancelled.
Use it after yields or before side effects.
Example:
Runner.start(function(ctx)
task.wait(1)
if ctx.cancelled() then
return
end
print("Still valid")
end)
ctx.cancel()
Hard-cancels the current running task.
This is useful when the task decides to terminate itself immediately.
Example:
Runner.start(function(ctx)
if not workspace:FindFirstChild("Something") then
ctx.cancel()
return
end
print("Continuing")
end)
ctx.sleep(seconds) -> boolean
A cancel-aware replacement for simple timed waiting.
Returns
trueif the full duration completedfalseif the task was cancelled before finishing
Example:
Runner.start(function(ctx)
local ok = ctx.sleep(2)
if not ok then
return
end
print("2 seconds passed without cancellation")
end)
Unlike plain
task.wait(2), this can stop early when the task is cancelled.
ctx.waitSignal(signal) -> (boolean, ...any)
Waits for a signal in a cancel-aware way.
Returns
falseif cancelled before the signal firedtrue, ...followed by the signal arguments if it fired successfully
Example:
Runner.start(function(ctx)
local ok, hit = ctx.waitSignal(part.Touched)
if not ok then
return
end
print("Touched by", hit)
end)
Unlike
signal:Wait(), this gives the task a clean way to stop waiting if it becomes stale.
ctx.guard(fn) -> wrappedFn
Wraps a callback so it only executes if the task is still active.
Example:
Runner.start(function(ctx)
local safeCallback = ctx.guard(function()
print("Only runs if task is still alive")
end)
task.delay(1, safeCallback)
end)
This is useful when handing callbacks to other async systems that might fire later, after the task is no longer relevant.
Cancellation behavior
This Runner supports two cancellation modes.
Hard cancel
cancel()
cancel(true)
Runner.cancel("Job")
Runner.cancel("Job", true)
Hard cancel does this:
- marks the task as cancelled
- runs
oncancel - calls
task.cancel(thread) - cleans up metadata immediately
Use this when the task should stop right now.
Soft cancel
cancel(false)
Runner.cancel("Job", false)
Runner.cancelGroup(group, false)
Runner.cancelAll(false)
Soft cancel does this:
- marks the task as cancelled
- runs
oncancel - does not call
task.cancel
The task is expected to exit on its own by checking
ctx.cancelled(),ctx.sleep(...), orctx.waitSignal(...).Use this when you want cooperative shutdown instead of immediate thread termination.
Usage examples
Example 1 — Latest-only tab switching
local function openTab(tabName)
Runner.startLatest("OpenTab", function(ctx)
FadeOutCurrentTab()
if not ctx.sleep(0.15) then
return
end
ShowTab(tabName)
FadeInTab(tabName)
end)
end
Why this is useful:
- old tab transitions are replaced automatically
ctx.sleepstops early if a newer tab request cancels the task
Example 2 — Hover animation sequence with grouped cancellation
local hoverGroup = {}
local function playHover(button)
Runner.cancelGroup(hoverGroup, true)
Runner.start(function(ctx)
button.Size = UDim2.fromScale(1, 1)
GrowButton(button)
if not ctx.sleep(0.1) then
return
end
ShrinkButton(button)
end, hoverGroup, function()
StopButtonTweens(button)
end)
end
Why this is useful:
- only the newest hover sequence stays alive
- cancel cleanup can immediately stop tweens
Example 3 — Timed effect refresh
local function applySpeedBoost(character)
local key = "SpeedBoost_" .. character:GetDebugId()
Runner.startLatest(key, function(ctx)
SetSpeed(character, 32)
if not ctx.sleep(5) then
return
end
SetSpeed(character, 16)
end, nil, function()
SetSpeed(character, 16)
end)
end
Why this is useful:
- refreshing the effect replaces the old timer
- cancelling the task resets the speed immediately
Example 4 — Waiting for a signal, but cancellable
Runner.start(function(ctx)
local ok, finished = ctx.waitSignal(humanoid.MoveToFinished)
if not ok then
return
end
print("Move finished:", finished)
end)
Why this is useful:
- the task does not stay stuck waiting forever if it gets cancelled
Example 5 — Soft cancellation for cooperative shutdown
local cancel = Runner.start(function(ctx)
while not ctx.cancelled() do
print("Working...")
if not ctx.sleep(1) then
return
end
end
end)
cancel(false)
Why this is useful:
- no forced
task.cancel - task exits naturally through its own checks
Example 6 — Guarding a delayed callback
Runner.start(function(ctx)
local safeApply = ctx.guard(function()
print("Applying result")
end)
task.delay(0.5, safeApply)
if not ctx.sleep(0.2) then
return
end
end)
If the task gets cancelled before the delayed callback runs, the guarded callback does nothing.
Example 7 — Cancel all active work
Runner.cancelAll(true)
This cancels all tracked tasks, including named tasks, anonymous tasks, and grouped tasks, as long as they were started through this Runner and are still tracked.
Best practices
Prefer ctx.sleep over raw task.wait when cancellation matters
if not ctx.sleep(0.25) then
return
end
This avoids waiting longer than necessary after cancellation.
Prefer ctx.waitSignal over signal:Wait() when staleness matters
local ok, result = ctx.waitSignal(someSignal)
if not ok then
return
end
This prevents jobs from getting stuck on waits they no longer care about.
Check ctx.cancelled() after yields you do not control
task.wait(1)
if ctx.cancelled() then
return
end
Put must-run cancel cleanup in oncancel
Use
oncancelfor things like:
- stopping tweens
- resetting values
- disconnecting visuals
- hiding loading UI
- restoring state
Use startLatest for replaceable flows
Good fits include:
- menu transitions
- hover or click chains
- preview loading
- retargeting behavior
- effect refresh timers
- repeated NPC intentions
Use groups for anonymous related work
Groups are best when tasks do not need names, but should still be cancelled together.
Use guard for callbacks you hand off elsewhere
If code may run later outside your immediate control, guard it.
Examples:
task.delay- tween completion callbacks
- signal connections
- deferred UI actions
Limitations
This module is intentionally small and is not a full scheduler.
Soft cancellation is cooperative
If you soft-cancel a task, it only stops when the task code notices cancellation and exits.
That means your task should use
ctx.cancelled(),ctx.sleep(), andctx.waitSignal()appropriately.
Hard cancellation stops the thread, but your state design still matters
Even with hard cancel, you still need correct cleanup for game or UI state.
Use
oncancelfor cleanup that must happen immediately.
guard only protects the wrapped callback body
It does not unregister every external system automatically. It only prevents the wrapped function from doing work after cancellation.
ctx.waitSignal polls once per frame
It is practical and simple, but it is still a loop-based cancel-aware wait.
Groups are plain arrays
A group is just
{ thread }. Runner manages insertion and removal, but it is still your table.
Comparison to raw task APIs
Using raw APIs directly:
- simple at first
- easy to scatter around
- no built-in latest-only naming
- no grouped ownership
- signal waiting is awkward to cancel
- callback staleness is easy to miss
Using Runner:
- centralizes task ownership
- gives named latest-only jobs
- gives grouped cancellation
- supports soft or hard cancel
- provides cancel-aware waiting helpers
- supports guarded callbacks
- cleans up internal references automatically
Good pairings with this module
Runner works especially well with:
- a tween manager or tween bank
- Maid or Janitor style cleanup utilities
- UI controller modules
- NPC behavior or state systems
- status effect systems
A strong pattern is:
- use Runner for async flow ownership
- use Maid or Janitor for connections and instances
FAQ
Why not just use task.spawn and task.cancel directly?
You can, but once you need latest-only jobs, grouped jobs, cancellable waits, guarded callbacks, and consistent cleanup, it becomes repetitive very quickly.
Is this a replacement for Maid or Janitor?
No.
Runner handles task ownership and cancellation flow.
Maid or Janitor handles resource ownership like connections, instances, and cleanup bundles.They pair well together.
Does this help with memory cleanup?
Yes, in the sense that Runner removes its own internal references when tasks end or are cancelled.
But your own code still needs to avoid keeping unnecessary references alive.
When should I use start vs startLatest?
Use
startwhen tasks are independent.Use
startLatestwhen a new request should replace the old one.
When should I use soft cancel instead of hard cancel?
Use soft cancel when you want the task to shut down cooperatively through its own logic.
Use hard cancel when the task should stop immediately.
Why does ctx.waitSignal return false on cancel?
So your code can stop cleanly instead of waiting forever for a signal that no longer matters.
Why use guard if I already check ctx.cancelled()?
Because callbacks often run later and outside the main function body.
guardmakes those callbacks automatically stale-safe.
Notes
cancelAll()now cancels all tracked tasks, not only named onesstartLatest()hard-cancels the previous task with the same namectx.sleep()andctx.waitSignal()returnfalsewhen cancellation happens firstctx.guard(fn)prevents stale callback executiononcancelruns on both soft and hard cancellation