Building a 44 Hz Engine That Doesn't Flinch
/ 5 min read
Table of Contents
One of the nice things about writing about performance is that people expect a heroic optimization story.
This is not that story.
The engine in Supervision runs at roughly 44 Hz. On each tick it resolves the active scene state, writes DMX bytes into a preallocated buffer, frames the Art-Net packet, and sends it. The real work was not making that fast. The real work was making sure nothing slow accidentally wandered into the loop.
Live photo — tube lights, laser, and reflective panels carrying across the room during the set, 5/14/2026. Photo by Mariah Tiffany.
The architecture that matters most
The engine loop runs in a worker_threads worker. The main thread owns the parts of the system that are allowed to be chatty or blocking:
- WebSocket connections
- SQLite writes
- Radiator TCP
- process lifecycle and logging
The Pi coordinates more than lights: client sync, persistence, and laser preset control all route through it, but the lighting worker still owns exactly one concern: evaluate the current lighting state and send output. Radiator is integrated and supported in that system, but it is optional at runtime, not a permanently attached part of the engine path.
That split matters more than the specific timer implementation. It means a database write, a burst of control traffic, or some noisy logging path cannot steal time from the frame loop because those things do not live on the same thread.
That is the main performance story.
Why 44 Hz is enough
The engine target is based on the actual output path, not on some abstract idea of “real time.” The Aurora is already buffering network DMX before forwarding it to the fixtures. That means the system is tolerant of small timing variation upstream.
I do not need a perfect, hard-real-time scheduler. I need a loop that is steady, predictable, and free of self-inflicted pauses.
That is why the timer strategy is aggressively unromantic:
const PERIOD_MS = 1000 / 44;let lastTick = process.hrtime.bigint();
setInterval(() => { const now = process.hrtime.bigint(); const elapsed = Number(now - lastTick) / 1_000_000;
if (elapsed < PERIOD_MS * 0.8) return;
lastTick = now; tick();}, 20);There is no busy-waiting, no “look what I got away with in C++” move, and no attempt to turn Node into something it is not. The timer runs slightly under the target period, hrtime keeps the measurement monotonic, and early wakeups get skipped.
That is enough for this workload, and pretending otherwise would mostly be ego.
The real enemy was garbage collection
If I had to name the most useful constraint in the engine, it would be this one:
Nothing in
tick()gets to allocate.
That rule does a lot of work.
The hot path uses preallocated buffers:
const dmxBuffer = Buffer.alloc(512);const artnetPacket = Buffer.alloc(530);It also avoids the usual death-by-convenience problems:
- no object spreads
- no array rebuilding
- no throwaway string work
- no hidden allocations inside helper functions
This is not because the loop is computationally expensive. It is because the easiest way to make V8 a non-story is to stop feeding it short-lived garbage on the timing-sensitive path.
For a system like this, “fast enough” is easy. “Fast enough without occasional surprise pauses” is the real target.
What runs where
The main thread holds the authoritative hot state. The iPad is the primary surface, the iPhone is a narrower companion, and both ultimately talk to the same Pi-owned state. When a command changes that state, the main thread posts a patch to the worker. The worker applies it between ticks and uses its local copy on the next frame.
That gives me a clean separation:
- main thread decides what the state is
- worker turns that state into output
The worker is intentionally not a mini-application with its own side quests. It does not talk to SQLite. It does not manage WebSocket clients. It does not speak Radiator TCP. It does not know about the rest of the world more than it has to.
This is the kind of discipline that feels slightly boring when you are building it and extremely smart when you are debugging at midnight.
Making degradation visible
I also wanted the engine to admit when it was under pressure instead of pretending everything was fine until the output looked bad.
So the worker tracks skipped ticks and reports metrics back to the main thread. If the skip count crosses a threshold, the observability path can surface that as a degraded state in the UI and logs.
I like this kind of instrumentation because it turns “I think something felt off” into a concrete signal. A lot of performance work gets framed as shaving nanoseconds off the happy path. Here it was more useful to make trouble observable before it became visible in the room.
The line I wanted to hold
It is worth saying out loud that the boring version won here. A lot of systems engineering is deciding what not to build after you understand the escape hatch. The implementation in supervision already has the structural decisions that matter most:
- dedicated worker
- no blocking I/O in the worker
- no hot-path allocation
- metrics for timing degradation
Everything beyond that is just noise unless the measurements say otherwise.
Deterministic output is one layer of the system. The next layer is persistence and recovery: what survives a restart, how state comes back, and how the rig resumes without drama.