Sixteen milliseconds, and fourteen of them are the strip

We put a Raspberry Pi, an ESP32, and an LED strip on a single clock to find
out where the time goes between "sendto() returned on the Pi" and "the pixels
changed." End to end was 16.35 ms. The strip's own wire timing was 14.4 ms of
it, 88% of the path, and that number is a multiplication, not a performance
figure. Nothing we could have optimized was ever going to touch it.
Two histograms of the same 3552 frames. Send to frame committed centers on 1.42 ms and is a few hundred microseconds wide. Send to pixels latched centers on 16.35 ms and is the same narrow shape shifted right by the strip's fixed 14.4 ms of bit timing.
Two machines, no shared clock
A frame in our system crosses two machines and a wire. A Raspberry Pi renders it and sends it over the network. An ESP32 receives it, reassembles it, and then clocks it out to the strip itself, six data lines in parallel off its I2S peripheral. Some time after that the pixels change.
Asking how long that takes is easy. Answering it is awkward, because the two machines do not share a clock. The Pi's idea of now and the ESP32's idea of now are unrelated, and the strip does not have an opinion at all. Timestamp on both ends and subtract, and you have measured clock offset as much as latency. On hardware this cheap the offset is neither small nor stable.
The usual answers are to synchronize the clocks or to give up and measure round trips. We did neither.
One clock, borrowed from the test equipment
A logic analyzer samples every channel against the same timebase. That is its entire job. So rather than synchronize two clocks, we gave every event a pin and let the analyzer be the only clock in the system.
Four signals, one capture:
- L_SEND, a GPIO on the Pi, pulsed immediately after
sendto()returns. - L_RX, a GPIO on the ESP32, pulsed in the UDP receive loop before any parsing, once per datagram.
- L_FRAME, a GPIO on the ESP32, pulsed when a complete frame is committed and handed to the renderer.
- The LED data line, which needs no instrumentation at all. The end of the last bit is the moment the pixels latch.
Every timestamp is now a rising edge in one file, on one clock. Latency is subtraction. No offset estimate, no round trip standing in for a one way measurement. The Pi's GPIO and the analyzer sit on the same board, so they share a ground for free.
The probes cost two of the six LED outputs on the test unit. Those two strips go dark while a measurement runs, which is fine on a bench and would not be fine on a shipping unit. Both compile out by default.
What sixty seconds looked like
One control run: 3600 frames at 60 fps, 2880 pixels per frame, quiet wired network. 65 seconds captured, 21,600 datagrams sent, and 21,600 send pulses on the wire to match.
| segment | median | 95th pct | 99th pct |
|---|---|---|---|
| network, send to first packet arriving | 360 µs | 1097 µs | 1201 µs |
| receiving the rest of the frame | 839 µs | 1204 µs | 1296 µs |
| reassemble and commit the frame | 79 µs | 91 µs | 92 µs |
| render, then clock the bits out | 14,930 µs | 14,945 µs | 15,087 µs |
| end to end | 16,351 µs | 16,683 µs | 16,952 µs |
The last two columns are the 95th and 99th percentiles: the value that 95% and 99% of frames came in under. They are there because a median alone hides the tail, and the tail is what you actually feel.
A five second run taken hours earlier agrees with every one of those medians to within 20 µs. Delivered frame rate, computed from the spacing of the latch edges rather than from any counter, was 60.013 fps.
The medians do not sum to the end to end median, and should not. Each row is the median of its own distribution.
One number in that table is not fully reconciled. 3600 frames were sent, and 3552 of them came out of the matcher with a complete set of edges and form the population the percentiles are computed over. Where the other 48 went, whether they fell across a capture boundary or were rejected by the matcher, we have not chased down. It is 1.3% of the run and it could bias the tail either way.
The 14.4 ms was never in play
The last row, render plus transmission, is 91% of the total. The physical bit timing inside it is 14.4 ms, or 88%. Most of that row is not a performance characteristic. It is a multiplication.
These are WS2812 style pixels. Every bit takes 1.25 µs on the wire regardless of what it says. A pixel is 24 bits, so a pixel is 30 µs. The test unit runs 480 pixels on each data line:
480 pixels x 24 bits x 1.25 µs = 14.4 ms
That is the floor. Not the cost of our driver, not something a faster processor improves. It is how long it physically takes to shift 11,520 bits down a wire at 800 kHz. Our measured 14.93 ms for render plus transmission sits half a millisecond above the arithmetic, and that half millisecond is the only part of the row that was ever ours.
Everything upstream, the network transit and the reassembly and the render together, came to roughly 1.4 ms. Under 9% of the path.
That agreement is also the best evidence we have that the clock trick works. Measured 14.93 against arithmetic 14.4 is a half millisecond of render on top of a number derived from a datasheet. If the four probes were not really on one timebase, it had no reason to land there.
Latency is geometry
The number is set by pixels per data line. Not by pixel count, and not by anything running on a processor.
Our test unit drives 2880 pixels. On a single line that is 86 ms of wire time, and the display would be uselessly late no matter how fast everything upstream got. Split across six lines driven in parallel, all six shift simultaneously and the frame costs 14.4 ms. Same pixels, same processor, same network, six times better, purely from how the copper is arranged.
So when a rig feels late, the first question is not how fast the controller is. It is how many pixels sit on the longest data line. Multiply by 30 µs. If that already exceeds your frame budget, nothing upstream matters yet.
That rule is about this family of parts. It holds because a WS281x chain is a long unclocked shift register with a fixed bit period. Clocked strips like APA102 run the data as fast as you drive the clock, and a rig with local buffering, different latch semantics, shorter chains, or a queue upstream can easily have its dominant term somewhere else entirely.
None of this is hidden. It is in every WS2812 datasheet as a bit period. It is just rarely multiplied out and set beside a measured end to end number, and the multiplication is the whole point.
Three things that lied on the way
Two of them looked perfectly healthy. The third announced itself by going negative, which made it the easy one.
A negative latency. The first decode had packets arriving 16,249 µs before they were sent. The matcher paired the Nth send with the Nth receive, and one stray receive group at the head of the capture shifted every pair by a frame. The offset was exactly one frame period, which is what gave it away. A lucky bug. Negative latency is obviously nonsense, and the same mistake with the sign the other way would have published quietly. It matches by time now, not by position.
Pulses that swallow each other. The receive probe undercounted: 19,705 pulses, where the frames it committed required at least 21,342. A 2 µs pulse is not atomic. If the operating system preempts the task between setting the pin and clearing it, the pin stays high, and any packet arriving inside that window is absorbed into the same pulse. Its "set" does nothing and its "clear" ends a pulse that was already running. Against a 2.000 µs intended width we measured pulses of 8, 13, 73, and 226 µs.
Timing mostly survives this, because the first edge of each group is set before anything can be absorbed into it and that is what the latency numbers use. It is still the moment the receive task got to run, not the moment the packet arrived at the MAC, so a preemption before the pin goes high moves it. Counts do not survive at all. They are a lower bound and cannot be used for packet loss. The decoder now catches it on its own, by noticing that the committed frames imply more packets than the pin reported, and prints it on every run.
We did not fix it by disabling interrupts around the pulse. Two microseconds of masked interrupts, hundreds of times a second, is exactly the perturbation we spent days chasing in this same firmware for an unrelated reason. An instrument that changes what it measures is worse than a missing number.
A capture that stopped early and said nothing. We asked the analyzer for 10 seconds and got 3.707, with no error and no warning. The compressed capture container was the bottleneck. Writing the raw sample stream instead, into memory rather than onto the SD card, returned exactly 20,000,000 and then 65,000,000 bytes for 20 and 65 second requests. Every run now compares the samples it got against the samples it asked for. A short capture that decodes cleanly is the most dangerous kind of wrong.
What we would tell someone with a late rig
Count the pixels on your longest data line and multiply by 30 µs. If that is already most of your frame budget, the fix is in how the strips are divided and driven, not in the controller.
And if you want to know where your own time goes, you do not need synchronized clocks. You need one clock that can see everything, and a few spare pins.
We spent three days building an instrument to find out what was slow. It was the copper.
Measured on a bench pole: ESP32 on wired Ethernet, six data lines of 480 SK6813MINI pixels, frames rendered on a Raspberry Pi 4 and sent at 60 fps. Capture is an FX2 logic analyzer at 1 MHz, 65,000,000 samples per control cell. Send pulses were cross checked against the sender's own log, 21,600 against 21,600, and every figure above comes from edges on that capture rather than from any counter inside either machine.