There’s a specific kind of learning that only happens when you impose absurd constraints on yourself.
I’ve built backend systems at scale. I’ve worked with distributed databases, event-driven architectures, real-time sync pipelines. But nothing taught me more about data locality and network reliability than wiring a 2.9-inch e-ink display to an ESP32 and asking it to track my expenses.
This is the build log for that project.
Why an ESP32? Why an e-ink display?
The pitch I gave myself: I want a single-purpose device. No notifications. No distractions. You pull it out of your pocket, punch in what you spent, it syncs to Supabase, and you put it back.
The e-ink display was a deliberate constraint. E-ink means no backlight, no constant refresh. You get around 10,000 page refreshes before degradation, which at a rate of a few per day gives me years of use. Battery life goes from hours to weeks.
The Gameboy form factor was the most honest answer to a simple question: what shape encourages quick input and nothing else? Portrait orientation, four directional buttons, two action buttons, a select and start. That’s the entire UI surface. You can’t browse social media on it. You can’t even look at a notification. It does one thing.
The circuit
The core is a Waveshare 2.9-inch e-ink HAT paired with an ESP32-WROOM-32. The display connects over SPI — four data lines, a chip select, a data/command pin, and a reset. It’s not complicated, but e-ink displays are notoriously sensitive to initialization sequences. The first two hours of this project were debugging why the display showed nothing but static.
The fix: you have to send a full hardware reset (pull RST low for 10ms), wait for the BUSY pin to go low, then send the initialization sequence. If you send commands while BUSY is high, the display silently ignores them and you spend time wondering if your SPI config is wrong.
The buttons are simple pull-up GPIO inputs with a 10ms software debounce. Nothing fancy. The input loop polls every 50ms, which gives responsive-enough feedback without burning CPU.
void loop() {
if (digitalRead(BTN_UP) == LOW) {
handleInput(INPUT_UP);
delay(10); // debounce
}
// ... other buttons
if (pendingSync && WiFi.status() == WL_CONNECTED) {
syncToSupabase();
pendingSync = false;
}
delay(50);
}
Battery life and the sleep tradeoff
The ESP32’s deep sleep mode drops power consumption from ~240mA (active with WiFi) to about 10 microamps. The tradeoff is that waking from deep sleep takes around 300ms, and you lose anything in RAM.
My current approach: the device stays in light sleep (not deep sleep) when idle. Light sleep keeps RAM alive, wake time is ~5ms, but power is higher — around 1mA. With a 1200mAh battery, that’s about 50 days of standby. Good enough.
WiFi is the killer. Connecting to a network, syncing to Supabase, and disconnecting takes roughly 3 seconds and burns significant battery. I batch syncs: entries queue locally in SPIFFS (the ESP32’s onboard flash filesystem), and sync happens once per hour or when you explicitly trigger it.
Supabase as the backend
I chose Supabase for the hosted Postgres and the REST API you get for free. The ESP32 doesn’t run a full HTTP library well — I’m using the ESP32’s built-in HTTPClient with a small JSON serializer I wrote to keep the binary small.
The sync payload is simple:
{
"device_id": "esp32-01",
"entries": [
{ "amount": 4.50, "category": "coffee", "timestamp": 1748774400 }
]
}
Supabase’s REST API makes this a single POST to /rest/v1/expenses. Row-level security ensures only entries with the matching device_id can be inserted — the ESP32 holds an API key that’s tied to that device.
Supabase Realtime wasn’t something I needed on the device side, but I do use it on a companion web dashboard to see new entries appear as they sync.
What I’ve learned so far
E-ink is slower than you think. A full refresh takes 2 seconds. Partial refresh is faster (200ms) but leaves ghosting artifacts over time. The right pattern is partial refresh for most updates, full refresh every 20th write.
SPIFFS is not a database. I burned an entire evening building a simple linked-list structure for queued entries before I remembered that flash has a write cycle limit. The right approach: write a fixed-size circular buffer at a known offset. Simpler and kinder to the hardware.
The constraint-first approach works. By choosing hardware that can only do one thing, I ended up with a much cleaner data model and a sync protocol I actually understand end-to-end. There’s no “just add another field” temptation when your UI has five buttons and 296x128 pixels.
What’s next
I’m working on the enclosure now — 3D-printed, Gameboy-shaped, with a slot for the battery and a cutout for the display. Once the physical form is settled, I’ll open-source the firmware and the Supabase schema.
If you’re curious about the specifics — the SPI initialization sequence, the SPIFFS circular buffer, or the Supabase RLS setup — let me know in the comments. I’ll turn any of those into follow-up posts.