Skip to content

Performance

What ChloeKernel costs at idle and under load, the per-player and per-server limits it enforces, and the scaling work that remains, with the numbers behind each claim.

An idle ChloeKernel server does almost no per-frame work, a busy one spends a bounded slice of each frame, and the transport caps what any one client can make the server do. Replication and persistence have their own costs, and some scaling work is still open. Most figures below are operation counts from source and from the isolated regression runners, which use engine and storage doubles. They count algorithmic work; none of them is a measured Roblox frame time. Benchmarks has the per-call microbenchmarks, and Event-driven execution explains the model the idle numbers come from.

A bound Scheduler with nothing due has no frame connection and at most one delayed thread, armed for its earliest deadline. Framework systems start their jobs on the first work item and cancel them on the last, so an empty server runs none of these:

System Cadence while it has work Idle since
Projectiles 30 Hz 0.8.0
Sweeps 30 Hz 0.8.0
Hazards 8 Hz 0.8.0
Effects 10 Hz, only while a timed or ticking effect exists 0.8.0
Zones 4 Hz while zones exist; overlap queries skipped when nobody is present and every zone is empty 0.8.0, queries 0.9.0
Movement Monitor 5 Hz while a session is tracked 0.9.0
Rewind 20 Hz while a session is tracked 0.9.0
Forensics 10 Hz (RecordHz) while players are tracked 0.9.0
Leaderstats 1 s sweep while sessions exist 0.9.0
MeleeKit timeline 20 Hz while a combatant is mid-action 0.9.0
CompanionKit follow 4 Hz while a companion is summoned 0.9.0
SpellKit regeneration 1 Hz while someone is below MaxMana after a cast 0.9.0
Matchmaking coordination every 5 s while this server has local queued players 0.9.0

At their default rates, Projectiles, Sweeps, Hazards, Effects and Zones alone add up to 82 scheduled callbacks per second; with no work they make none. CraftingKit (was a 4 Hz sweep), ProceduralKit (10 Hz), PartyKit (a 5 s sweep) and Stages (a 1 s loop) no longer run loops: CraftingKit arms one timer per timed craft, ProceduralKit one per buffered write, Stages one per empty stage, and PartyKit expires invites lazily when they are next touched. RoundKit arms one deadline per phase and ticks its replica countdown only while a timed phase runs.

The core regression runner installs 1,000 future timers, advances 120 engine frames before their deadline and observes zero scheduler steps. That proves the idle algorithm as an operation count.

Clients follow the same rule. HazardClient and ProjectileClient connect PreRender only while entries are live, Impact and CameraKit bind a render step only while active, AudioKit, AnimKit, CutsceneKit, MaterialKit, BonePhysics, VfxSuite and Dissolve connect only while they have work, and Time has no frame connection. Closed F8 panels run no render tasks, samplers, log sink or NET capture. Gait keeps one PreSimulation connection while any rig is bound, because it samples continuously.

The server scheduler is frame-aware: each step spends the slack under a 16.5 ms target, never less than a 1.5 ms floor. Clients use a fixed 1.5 ms slice because Stats.HeartbeatTime does not include render time, and a frame-aware client budget would fill frames the render thread needs. Budgets read Stats.HeartbeatTime and Stats.PhysicsStepTime (seconds) where the engine has them and fall back to the deprecated *Ms properties.

  • The budget is checked between callbacks. It cannot interrupt one large callback, and Kernel priority work is exempt from it. Break large replica, combat and hazard loops into resumable batches, and measure both the longest callback and how long low-priority work waits in the queue.
  • Task timings measure elapsed time with yields included, so they overstate the CPU cost of a callback that yields. ProfileTasks defaults to true; turning it off at boot skips the per-callback clock reads, origin attribution and deferred-queue scans while keeping aggregate budget statistics. kernel:enableDiagnostics() turns it back on.
  • MicroProfiler zones around every task are on by default only in Studio (Scheduler.setMicroProfilerZones(true) opts a live server in).
  • StaggerRecurring = true spreads the first run of positive-interval jobs across their interval, so jobs created in the same frame do not all fire together.

ReplicaService delivery is dirty-driven. Writes, roster changes and interest invalidation wake it; several changes coalesce until the replica’s next deadline, and setting an unchanged scalar does not wake the scheduler. Replicas with the same tick rate and priority share one job, and a waking group visits only its work set: dirty replicas, undelivered governor debt, invalidated interest and configured interest pollers. A deterministic 200-replica regression observes only the changed replica being visited after the initial subscriptions settle, with both manual and default interest. Tick-rate groups share a player roster, new subscribers share one encoded snapshot, and deltas encode only when a recipient can receive them. Clean replicas with no interest polling leave no scheduled job.

Custom Interest callbacks still poll by default, once per TickRate. R polling replicas, P players and H checks per second cost about R x P x H callback runs: 1,000 replicas, 50 players and 10 Hz is 500,000 interest checks per second. Use InterestMode = "Manual" with refreshPlayer or invalidateInterest for ownership and membership rules, or a slower InterestRefreshSeconds. Distance-based interest still needs sampling. refreshPlayer visits every replica for that player.

On the client, ReplicaClient commits a whole packet before callbacks and offers OnBatch(changes) for one view refresh per packet. The batch records allocate on every changed packet, so a view saves work only when it moves its combined refresh onto OnBatch and drops the per-key refreshes.

Wire costs added in 0.9.0:

Change Cost
Replica protocol V2 (CKRep2_*): U16 slot, U32 generation, U32 revision 8 bytes per replica packet over V1
Server-to-client frame length (varint after the channel id) 1 byte per frame under 128 body bytes, 2 under 16 KB, 3 beyond
Unreliable state sequence footer 2 bytes per datagram; payloads up to 980 bytes including it (the engine limit is 1,000)

Unreliable broadcasts use one FireAllClients while no player is rated Poor by the NetGovernor, and per-player sends only when a "Low" channel has someone to skip. ReplicaService accepts MaxLiveSlots (default and ceiling 65,535) to cap replicas alive at once.

Packet guards the client-to-server path before any decode or task allocation. All of these are per player:

Limit Default Set by
Byte window 8,000 bytes per heartbeat; each remote call is charged at least 800 bytes RateLimitBytes attribute on the Packet module
Message budget 240 messages per second, burst 128 RateLimitMessages, RateLimitMessageBurst
Typed array entries 8,192 per logical message, nested arrays included, on both sides MaxDecodedArrayElements
Pending requests 128 request slots; a slot stays reserved until its handler finishes fixed
Response timeout 10 s ResponseTimeoutSeconds on the Packet module, or per channel ResponseTimeout
Parked server-to-client frames 64 per channel, each for at most 10 s fixed

These bound the flood path the regressions test (many tiny frames). Handler execution time is outside them, so a handler still needs its own bounded work. The Packet heartbeat pump disconnects after 1 second without traffic. Net.FloodDetected reports are coalesced to one per player and reason per heartbeat window. NetGovernor samples ping only while sessions exist.

  • Autosave runs every 60 s (AutosaveSeconds) and spreads the saves over a quarter of the interval. Each profile saves on its own thread, and a profile whose previous save is still running is skipped, so one stuck call never delays the others’ lock renewal (the autosave is the lock heartbeat; LockTtlSeconds, default 180, must be at least twice the autosave). Ordinary saves are deferred while the DataStore request budget is spent; releases and shutdown always save. Nothing caps how many saves are in flight when the backend slows down, and each save copies the profile before yielding, so allocation and encoding scale with profile size.
  • Lock handoff adds one MemoryStore write per server every 3 s (presence, 10 s TTL), one read per wait step while a joining player waits on another server’s lock, and two MessagingService messages per handoff. A crashed server’s locks are taken within about 15 s.
  • A two-player trade performs six backend updates (create journal, prepare A, prepare B, decide, settle A, settle B), then removes the journal. The journal holds both encoded profiles in one record, so their combined size must fit the backend’s per-record limit even when each profile saves on its own.
  • DataStore storage above 500 MB + 1 MB x lifetime users has been billed since April 2026. Each backup slot and the Mirror slot is a separate key, so Keep = 3 plus Mirror can store up to four times the primary profile. Native version history, which DataDriver restores corrupt records from by default, adds no keys.
  • DataDriver warns once a profile passes 75 percent of the 4 MB value limit and refuses a save over it. Throttling errors retry with wider, jittered spacing within the same total retry time, and Data.Throttled publishes once per episode. MemoryDriver and MessageDriver jitter their retries by up to 25 percent.
  • MessageDriver enforces MessagingService limits: topics of 1 to 80 characters, 600 + 240 x players sends per minute and 20 + 8 x players subscriptions per server. Share topics instead of subscribing per feature.
  • On close, the service stop phase has 25 s. The DataDriver drain waits for Kernel.Stopped up to 5 s and finishes 27 s after the close starts. Leaderboards drains within ShutdownSeconds (default 10) and drops a value that fails MaxWriteAttempts (default 10) consecutive windows.

0.9.0 cut about 3.4 s from the Studio spec gate. TestKit used to wait one frame after each of the 104 spec modules to reset the engine’s per-resumption execution budget (about 1.7 s per run); it now yields through the deferred queue, which costs no frame. The Replica slot specs created 131,000 replicas to reach the 16-bit slot limit (about 1.7 s of CPU); with MaxLiveSlots the exhaustion spec fills 16. Measured in Studio, the shared suite went from 4.46 s to 3.75 s and the server suite from 12.52 s to 10.08 s. See Spec gate.

In priority order, from docs/performance.md:

  1. Autosaves and lease renewal. Nothing caps saves in flight under a slow backend. Measure the oldest unrenewed lease and p95 and p99 save duration under load.
  2. Continuously changing replica interest. Polling interest costs R x P x H as above. Use manual invalidation where the rule has events, or a slower interest interval.
  3. Inventory snapshots. InventoryKit sends the whole slot and equipment state after each change, so work and bandwidth grow with inventory size times mutation rate. Slot deltas need a coordinated client protocol change, so the current wire format stays for now.
  4. Combat broad phase. Generic melee swings visit the combatant roster, and hitscan rewind checks target histories and builds exclusion lists. Restricting candidates spatially before exact tests, and population caps for dense projectile, hazard and sweep workloads, are still open.
  5. Scheduler callback size. A deadline heap removes idle scans but cannot split an expensive callback, and a frame budget is not a hard ceiling.
  6. Remaining cadences. Effects’ 10 Hz sweep while a timed or ticking effect exists, Zones’ 4 Hz queries while zones exist, Leaderstats’ 1 s sweep while sessions exist, Matchmaking’s queue reads while local players are queued, and LiveConfig’s MemoryStore reconciliation poll still run on a cadence while their owner has work. A sleeping scheduler removes the timer overhead around them; the callbacks themselves still run.

The seven-bit RPC response id also cannot tell an extremely late response apart from a newer one after the id is reused on the same channel. Fixing that needs a generation token in the wire protocol and a coordinated client and server rollout.

  • A full Studio play of the multiplayer templates, including landing, death during delayed attacks, damage interruption and interest transitions.
  • Stale lease takeover, interrupted trades, provider throttling, slow saves, purchases during profile loading and late teleport failures against real DataStores. SoftShutdown retries teleports, but migration can still race a slow DataDriver shutdown flush.
  • Lock handoff latency, stale-connection takeover and crashed-server takeover between real servers.
  • Representative and peak load profiles: frame p95 and p99, longest callback, queue age, heap growth, bytes per player per second, snapshot size, autosave cycle time and DataStore failures.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.