Engineering Journal

← all posts

The LED protocol bottleneck we solved—and the one we created

NetworkingFirmwareProcess

A PoleFX pole grew from twelve DMX universes to seventeen, pushing its receiver past a thousand packets per second. We replaced those universes with six large UDP packets and got the performance we needed. Five years later, an audit found an existing open protocol with the same design—and a six-slot receive queue that had been quietly eating a third of our frame rate.


Bottleneck: seventeen packets per frame

The first two generations of PoleFX poles streamed pixels over sACN. Their 255 × 8 frame held 2,040 RGB pixels, or 6,120 bytes: exactly twelve DMX universes once each universe was limited to 510 usable color channels. It was a clean fit and it worked.

The third generation grew to twelve columns around the pole. Its 240 × 12 frame held 2,880 pixels, or 8,640 bytes. The same image now needed seventeen universes.

At 60 frames per second, that meant 1,020 UDP packets per second for one pole. The 100 Mbit Ethernet link was nowhere near full. Packet rate was the problem: every datagram still needed an Ethernet, IP and UDP header, a receive buffer, a mailbox slot, a trip through lwIP and a task wakeup.

Art-Net would have reduced the header overhead, but not the packet count. It still carries pixel data in DMX-sized universes. The framebuffer had outgrown the universe as a useful unit.

Our original explanation was less precise. We said seventeen universes required seventeen sockets, beyond the ESP32's socket limit. That was wrong. A receiver can join many multicast groups on one socket and dispatch on the universe number inside each sACN packet.

There was a related ESP-IDF limit: its lwIP build tracked only ten IPv4 multicast memberships globally, with Kconfig capped at sixteen. But we never ran the test that would have separated that implementation limit from the packet-rate problem. Our reading is that the enduring constraint was simpler: seventeen small datagrams were too much ceremony for one frame. That is the explanation we believe, not one we isolated, and the audit below specifies an eight-arm test that would settle it. We have not run it.

Solution: six MTU-sized chunks

In 2021 we wrote a small UDP format called bigPacket. Its eight-byte header carried a frame number, packet number, resolution and payload length. Each datagram then carried 1,440 bytes of RGB data—the largest convenient whole-pixel payload under a normal Ethernet MTU.

The change was immediate:

sACN bigPacket
Pixel payload per packet 510 B 1,440 B
Packets per frame 17 6
Packets per second at 60 fps 1,020 360
Link use at 60 fps 5.56 Mbit/s 4.30 Mbit/s

Link use counts each packet as its payload plus 46 bytes of Ethernet, IPv4 and UDP framing and FCS, and excludes the 8-byte preamble and the 12-byte interframe gap. Count those too, the way the DDP specification does, and the same two figures read 5.72 and 4.36 Mbit/s.

One 2,880-pixel frame on the wire, both protocols drawn to scale

The same 8,640 bytes of pixels. The important change was not the modest drop in bandwidth; it was 2.8 times fewer packets.

bigPacket assembled all six chunks before publishing a frame. A missing packet discarded the new frame and left the previous one on the LEDs for another 16.7 ms. Retransmitting would have delivered data after its display deadline, while publishing an incomplete frame would have produced visible tearing.

The 1,440-byte size also produced a useful accident. It is 480 RGB pixels: exactly one physical strip in a standard pole. No pixel crosses a packet boundary, so the receiver can apply gamma and map logical pixels to physical strips as each packet arrives, while the previous frame is still leaving over I2S.

Before that change, mapping ran in the LED task at the end of the frame. Instrumentation during a later logic-analyzer session measured it at 1.19 ms inside a 16.67 ms frame budget already occupied by 15.2 ms of LED transmission. Receive-time mapping reduced the render step to a 24-microsecond memcpy.

That was the real success: fewer receive events, plus work moved away from the hard render deadline. We have never priced those two apart, and the second half works under any protocol that delivers pixel-aligned chunks. It was a good solution to the bottleneck. It did not need to be a new protocol.

Discovery: the queue was exactly one frame deep

For years, our render instrumentation said the poles were running at 60 fps. Then we put a logic analyzer on the LED data lines and counted completed frames at the receiver. The as-found bench pole was publishing 41.1 fps.

The first loss came from a stray multicast stream. The Pi was transmitting the same pixels to two groups, although the pole had joined only one. The bench switch flooded the unwanted traffic anyway. Removing that stream raised output from 41.1 to 52.0 fps.

The remaining loss came from a setting we had once described as fortunate:

CONFIG_LWIP_UDP_RECVMBOX_SIZE=6

The UDP mailbox held six datagrams. A bigPacket frame contained six datagrams. We had taken the exact match as evidence that a delayed receive task could wake up to one complete frame.

In practice, a queue exactly one frame deep had no headroom for scheduling jitter, an old packet, a duplicate, a control message on the same socket or the first packet of the next frame. When the mailbox filled, lwIP silently dropped the incoming datagram; bigPacket then correctly rejected the incomplete frame.

Bench configuration Packets received Frames published Incomplete frames / 10 s
Two multicast streams, mailbox 6 290.8/s 41.1 fps 112
One multicast stream, mailbox 6 325.9/s 52.0 fps 4
One multicast stream, mailbox 24 358.8/s 59.6 fps 0

Those three rows come from one bench pole, and they were counted with the pole in DDP mode, whose frame is also six 1,440-byte datagrams. Whether bigPacket gains the same margin is an open question we wrote down and have not measured. The mailbox-24 run had no incomplete frames during the 50-second measurement. That is a useful bench result, not a long-term reliability bound. The durable fix is to size the queue from measured worst-case receive latency and expose its high-water mark and drop count. We had done neither.

The coincidence had not protected the protocol. It had become its ceiling.

Discovery: the protocol already existed

The same audit turned up DDP, the Distributed Display Protocol. It had been in xLights by early 2017 and was designed specifically to move pixel arrays without pretending they were collections of DMX universes.

Its header is ten bytes instead of eight. Its recommended payload is the same 1,440 bytes. It addresses a framebuffer with a byte offset and length, stages writes, and uses a Push flag to present them. It supports typed pixel data, short final packets and multiple destination buffers. WLED, xLights and LedFx already speak it.

We had not evaluated and rejected DDP. We had simply failed to find it. The matching payload size was not proof that our design was novel—or that every detail was correct. It was the arithmetic of fitting whole RGB pixels beneath an Ethernet MTU.

Once another implementation existed to compare against, our own format looked much less finished. The audit found mixed byte order, no magic number or useful versioning, three senders in the repo with two incompatible header shapes, and no sender identity. A delayed packet could also make an old frame current again because the receiver compared frame numbers for inequality instead of modular order.

The worst defect was a size calculation performed with signed integer arithmetic. A malicious or accidental resolution field could overflow, drive a huge allocation and reboot every listening pole. Bounds checks and a hard frame cap closed that path; modular frame ordering and source locking shipped in the same hardening pass. Source locking makes the first sender the owner, which is not authentication: nothing stops another host on the same LAN from claiming a pole first or drowning it, and that is equally true of sACN, Art-Net and DDP. Anything not reflashed still carries the old parser. (Update, 2026-08-05: an OTA sweep on 2026-08-02 went looking for that fleet and found two poles, both bench boards. No customer hardware is carrying the old parser.)

None of those defects caused the original packet-rate bottleneck. They were the maintenance cost of being the only implementer: no conformance suite, no independent receiver and no one else's traffic forcing ambiguities into view.

Adaptation: keep the useful properties, retire the format

We added DDP to both the Pi renderer and the pole firmware, then made it the default for new output devices. The data still travels in six 1,440-byte writes, so receive-time mapping and the 24-microsecond render step survive unchanged.

One useful bigPacket behavior needed to be rebuilt. Generic DDP buffers persist between frames so a sender can update only what changed. A Push does not guarantee that every byte in a frame arrived. Lose one chunk and a generic receiver may present five-sixths of the new image with one-sixth left over from the previous frame.

PoleFX therefore has an explicit atomic DDP mode. It tracks written byte ranges and publishes on Push only when the complete frame is covered. Otherwise it holds the previous frame and increments an incomplete-frame counter. Generic behavior remains available when atomic mode is disabled.

The fast receive-time map also assumes 1,440-byte-aligned writes. That is a documented PoleFX DDP profile, not a restriction in DDP itself, and arbitrary legal offsets still need a slower generic path if we want complete interoperability.

We added unicast sACN and Art-Net receivers too. They are not our preferred transport, but the point is that a customer's existing console or media server should be able to drive a pole without adopting our preference. No third-party console has done so yet. The senders on the bench were our own Pi and a Mac-side sender written from the specifications rather than from our code, and across ten-second runs they held 60.2 fps over sACN and 60.1 fps over Art-Net with zero rejects. Enabling both listeners and leaving them idle, one binary with one setting changed, moved the frame rate by 0.05 fps over twenty seconds, which is one frame of sampling quantization, and cost about 20 KB of heap. The heap is the real cost.

Finally, one host-side test generates golden packets for all four protocols and checks the Pi sender byte-for-byte. The firmware parsers compile without ESP-IDF and run under AddressSanitizer and UndefinedBehaviorSanitizer against those packets plus 200,000 generated datagrams. That is a repeatable guardrail, not proof against every parser failure, but it is far more than the custom format had for its first five years.

bigPacket is now deprecated: supported for deployed hardware, but receiving no new features. Moving the fleet to DDP is a firmware rollout problem rather than a protocol-design problem.

The original decision was not really custom protocol versus standards. It was MTU-sized pixel chunks versus DMX universes. We chose the right shape and got a large performance win. Then the audit exposed the two costs hidden behind that win: a queue with no headroom, and a private wire format with nobody else to correct it.

The adaptation was not to throw away what worked. It was to keep the packet size, the deadline-aware frame policy and the receive-time mapping—and stop owning the rest.