Skip to content
Engine

Runtime

SchedulerService

Spread heavy work over frames with time budgets, priority queues, deferred calls and throttling, so the game never hitches.

ServiceShared

Getting it

local SchedulerService = Engine:GetService("SchedulerService")

Overview

A frame lasts about 16 milliseconds. Doing too much work in one frame (spawning 500 NPCs, rebuilding a map, recalculating a leaderboard) causes a visible hitch. SchedulerService lets you hand work to the engine with a budget in milliseconds per frame; it does as much as fits each frame and continues on the next.

It has three tools:

  • Queues: named, long-lived lists of jobs with a per-frame budget and a priority.
  • SpreadAsync: process one array over as many frames as needed, returning a Promise.
  • Defer and Throttle: run something a few frames later, or at most N times per second.
Spawning a lot without a hitch
local SchedulerService = Engine:GetService("SchedulerService")

SchedulerService:SpreadAsync(spawnPoints, function(point)
	spawnTree(point)
end, { budgetMs = 3 }):andThen(function()
	print("forest ready")
end)

Every queue and spread runs on a RunService event, the phase. The default is Heartbeat. You can use any RunService event the current side has, such as PreSimulation, PostSimulation, or on the client PreRender.

Queues

CreateQueue

SchedulerService:CreateQueue(name: string, config: { budgetMs: number?, priority: number?, phase: string? }?): Queue

Creates a named queue. Queue names are unique; creating a second queue with the same name errors (use GetQueue). When several queues share a phase, lower priority numbers run first.

local pathing = SchedulerService:CreateQueue("NpcPathing", { budgetMs = 1.5, priority = 1 })

for _, npc in npcs do
	pathing:AddJob(function()
		npc:RecomputePath()
	end)
end

pathing.Drained:Connect(function()
	print("all paths updated")
end)

GetQueue

SchedulerService:GetQueue(name: string): Queue?

Queue

MemberDescription
:AddJob(fn): JobAdds a job. The returned Job has :Cancel(), which skips it if it has not run yet.
:Clear()Drops every waiting job.
:Destroy()Clears the queue and removes it from the scheduler.
Name, PhaseRead-only.
BudgetMs: numberMilliseconds per frame. Can be changed; must be positive.
Priority: numberCan be changed; queues are re-sorted.
Paused: booleanA paused queue keeps its jobs but runs none.
JobCount, MsUsedLastFrame, DestroyedRead-only.
Drained: SignalFires when the queue runs out of jobs.
JobErrored: Signal<err>Fires when a job errors. Other jobs keep running.

Jobs should not yield: a job that waits holds up the queue (and every queue after it in that phase) until it resumes.

GetFrameReport

SchedulerService:GetFrameReport(): { [string]: number }

Milliseconds each queue used last frame, by name. Handy for a debug overlay.

BudgetExceeded

SchedulerService.BudgetExceeded: Signal<(queueName: string, msUsed: number)>

Fires when a queue used more than its budget in a frame. A single job longer than the budget always overshoots, because a job is never cut off halfway.

Spreading work

SpreadAsync

SchedulerService:SpreadAsync(items: { any }, fnPerItem: (item, index) -> (), config: { budgetMs: number?, perFrame: number?, phase: string? }?): Promise

Calls fnPerItem for every item, using up to budgetMs per frame and, if set, at most perFrame items per frame. Returns a Promise that resolves when every item is done. Cancelling the Promise stops the work. An item that errors is reported with warn and the rest continue.

Timing helpers

Defer

SchedulerService:Defer(fn: () -> (), frames: number?): DeferHandle

Calls fn after frames Heartbeat frames (default 1). The handle has :Cancel().

Throttle

SchedulerService:Throttle(fn, hz: number, options?): (throttledFn, ThrottleControl)

Returns a wrapped function that runs fn at most hz times per second. This is RateLimiter.Throttle with a rate instead of an interval, and it takes the same options and returns the same control object.

local updateMinimap = SchedulerService:Throttle(function(position)
	minimap:Draw(position)
end, 10)

RunService.RenderStepped:Connect(function()
	updateMinimap(camera.CFrame.Position)
end)

Configuration

Values passed in a call win over these defaults. See Configuration.

Defaults from DefaultConfiguration/Services/SchedulerService5 settings
SettingDefaultWhat it does
DefaultBudgetMs2Milliseconds a Queue may spend per Phase tick before yielding.
DefaultPriority0Lower runs first when multiple Queues share a Phase.
DefaultPhase"Heartbeat"RunService event driving a Queue/Spread when none is given.
DefaultSpreadBudgetMs2Milliseconds :SpreadAsync() spends per tick when config.budgetMs is omitted.
DefaultDeferFrames1Frames :Defer() waits when none is passed.

Types

export type Queue
export type Job
export type QueueConfig = { budgetMs: number?, priority: number?, phase: string? }
export type SpreadConfig = { budgetMs: number?, perFrame: number?, phase: string? }
export type DeferHandle = { Cancel: (self: DeferHandle) -> () }