Runner — Lightweight cancellable task manager for Roblox

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 call task.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(), or ctx.waitSignal().

Cancel-aware waiting

Instead of plain task.wait() or signal:Wait(), you can now use:

  • ctx.sleep(seconds) which returns false if cancelled before completion
  • ctx.waitSignal(signal) which returns false if 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.sleep and ctx.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 receives ctx
  • group: optional task group table
  • oncancel: optional cleanup callback that runs when cancelled

Returns

  • cancel: (hardCancel?: boolean) -> ()

That returned function cancels that specific task.

If hardCancel is 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 key
  • fn: function that receives ctx
  • group: optional group table
  • oncancel: optional cleanup callback

Returns

  • cancel: (hardCancel?: boolean) -> ()

Runner.cancel(name, hardCancel?)

Cancels the current named task for name.

Parameters

  • name: job key

  • hardCancel: optional boolean

    • true or omitted means hard cancel
    • false means soft cancel

Returns

  • true if a named task existed
  • false if no named task was found

Runner.cancelGroup(group, hardCancel?)

Cancels all tasks inside a group.

Parameters

  • group: a JobGroup

  • hardCancel: optional boolean

    • true or omitted means hard cancel
    • false means soft cancel

Runner.cancelAll(hardCancel?)

Cancels all tracked tasks, not just named ones.

This is different from the older version.

Parameters

  • hardCancel: optional boolean

    • true or omitted means hard cancel
    • false means 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

  • true if the full duration completed
  • false if 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

  • false if cancelled before the signal fired
  • true, ... 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:

  1. marks the task as cancelled
  2. runs oncancel
  3. calls task.cancel(thread)
  4. 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:

  1. marks the task as cancelled
  2. runs oncancel
  3. does not call task.cancel

The task is expected to exit on its own by checking ctx.cancelled(), ctx.sleep(...), or ctx.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.sleep stops 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 oncancel for 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(), and ctx.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 oncancel for 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 start when tasks are independent.

Use startLatest when 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.
guard makes those callbacks automatically stale-safe.


Notes

  • cancelAll() now cancels all tracked tasks, not only named ones
  • startLatest() hard-cancels the previous task with the same name
  • ctx.sleep() and ctx.waitSignal() return false when cancellation happens first
  • ctx.guard(fn) prevents stale callback execution
  • oncancel runs on both soft and hard cancellation
1 Like

cool documentation!
i like it niceee