Building a live stock ticker on an ESP32-S3: the architecture, the memory budget, the mistakes

Ivan Duhov · 27 October 2026 · 5 min read

The most important number in this project is 22,380. It is the fewest bytes of internal RAM the chip had free at any moment during a six-hour overnight test, and the margin between a display that runs for months and one that quietly reboots at 3 a.m.

The project is Deskcast: a small LED panel for your desk that shows a live price, the day’s move and an intraday chart for the stock or coin you keep checking, with market news on the side. It looks like a gadget. Underneath it is a tight embedded-systems problem: stream a 128×64 LED matrix from an ESP32-S3, hold encrypted connections open to a data backend and update itself over the air, all inside the internal RAM of a microcontroller.

This post is the map: what is inside, how the pieces fit, and where it hurt. Later posts take each hard part apart.

The hardware: a screen with no brain

  • ESP32-S3 with 16 MB of flash and 8 MB of PSRAM, driving the panel from its LCD peripheral over DMA.
  • 128×64 RGB LED matrix (HUB75): 8,192 LEDs at a 2 mm pitch, 256 × 128 mm.
  • MAX98357A I2S amplifier and two 8 Ω speakers in parallel, for the headline chime.
  • One 5 V / 5 A supply for everything. A shared ground is not optional: without it, the panel flickers and its signals fail.

A HUB75 panel has no controller and no framebuffer of its own. The ESP32 must re-send every row, over and over, forever. The “screen” is a block of your own RAM being streamed out continuously, and anything else that wants that RAM is competing with the display.

The real constraint is internal SRAM

Flash is plentiful. PSRAM is plentiful. What is scarce is the fast internal SRAM, and five things need it at once: the panel buffer, Wi-Fi buffers, the TLS stack, the UI and the audio path.

TLS is the surprise. Every secure connection makes mbedTLS ask for a contiguous block of about 17 KB for its record buffer. Free memory is not enough; it has to be free in one piece. The device opens a fresh connection every 15 seconds, so fragmentation is a constant threat, not a one-off.

Here is the overnight run after the first round of optimisation, against the log from before it:

BeforeAfter
Core 1 busy~100% (a spinning loop())2.2% average
Network tasks3 tasks, 16 + 16 + 8 KB stacks1 task, 10 KB stack (5.7 KB peak used)
Lowest free internal RAM228 bytes22,380 bytes
TLS allocation failuresBIGNUM - Memory allocation failed0
Resets, crashes, watchdog trips—0

Before the fixes, the firmware was running with 228 bytes to spare, and it had survived only by luck. Measuring first (heap, stack and CPU telemetry came before any fix) is what made every later change defensible.

Drawing without a double buffer

A second full frame would cost more RAM than the device can spare, so the panel runs on a single live framebuffer, and every draw call writes straight into the buffer being scanned out. The rule that makes it work: only ever clear the region you are about to redraw.

  • A price tick repaints the header badge and the price band, and nothing else.
  • The chart refreshes on a slower cadence and clears only its own zone.
  • If price, change and session are all unchanged, the repaint returns without touching the panel at all, so a quiet market produces zero flicker.

Prices roll like a mechanical odometer: only the digits that changed slide, and they land left to right. The animations are time-driven state machines, so nothing blocks in a delay(). A late frame skips ahead instead of stretching the animation.

Updating itself, safely

A device on a desk you cannot reach has to update itself. The 16 MB flash is split into two 7 MB application slots, so a new build downloads into the idle slot, is verified, and only then becomes the one that boots. Releases are signed. If a build crashes on boot, the unit rolls back to the last good one and reports why.

That rollback path stopped being theoretical when a release crashed on boot during testing. The crash report named the cause and the unit recovered by itself. It is the reason the path exists in its current form.

Testing firmware without the hardware

Flashing a board for every change would make this unworkable, so most of the code is built to run anywhere. The drawing and animation maths live in files that do not depend on Arduino, with host unit tests. A simulator renders the real screen to an image. CI runs sanitizers and fuzzing, and a coverage gate keeps the tests honest.

The firmware’s rendering code is also ported to the browser: the animated panel on deskcast.app is the same drawing code, replayed against frames captured from the simulator, and a test fails if a single pixel differs.

What went wrong

  • An optimisation that worked and was still wrong. Moving the panel buffer into PSRAM freed about 70 KB of internal RAM: 170 KB free instead of 102 KB, with the largest block more than doubled. It also left the panel dark on roughly 1 boot in 13, while the logs looked perfectly normal. I measured it, rejected it and wrote down why. A fleet of units that nobody can reset cannot ship that.
  • A speaker that stayed silent while every log line said it was playing. The cause was in the hardware, not the firmware. That story is the next post.
  • A release that crashed on boot. Caught by the rollback and the crash report, and the reason both exist.

Coming next

  1. The silent speaker: perfect logs, no sound.
  2. 22 KB of RAM left: running a HUB75 panel and TLS on one chip.
  3. Over-the-air updates on a 16 MB ESP32, and the partition layout behind them.
  4. Testing firmware without the hardware.
  5. Turning raw market data into an intraday chart that survives pre-market, after-hours and half-days.

← All posts