The Optimistic UI Bet: Why Voice v2 Shipped Without Realtime
TL;DR
- Situation: Everyone kept asking "when will Voice be realtime?" Realtime required fighting eventlet + Redis DNS resolution inside a Docker container — 2–4 hours of uncertain debug with no guarantee it would work.
- My decision: Defer realtime. Ship optimistic UI everywhere (save/unsave, member counts, vote counts, comment counts). Document a specific fire-condition for when to re-open the decision.
- Outcome: v2.0.0 shipped on time. At beta scale, optimistic UI is user-indistinguishable from true realtime for 99% of interactions. Fire-condition documented so the decision has a clear expiration.
The problem
Voice v1 shipped with a Socket.IO connection I disabled after eventlet couldn't resolve Redis inside the container. Users started asking for "live updates" the same week I noticed the connection was hanging. Two different demands pointing at the same code.
Realtime sounds obvious for a community app. Everyone else has it, users expect it, and if a poll's vote count doesn't tick up in real time it feels dead. Except: what does "realtime" actually change for a user alone on a screen, doing one interaction at a time, at beta scale?
The constraint
One person, one week, App Store review pending. Realtime debug meant fighting eventlet's DNS monkey-patching, which stalled on Redis hostname resolution inside Docker's bridge network. Possible fixes: add dnspython, switch to gevent, switch to threading mode with Redis message queue, or run Flask-SocketIO without monkey-patching. Each path had a 2–4 hour debug loop with rebuild-deploy-test cycles. Total downside: could burn the whole week and still not have working realtime.
What I killed: spending the week on eventlet
The most tempting path was: "just fix it right." Deep-dive eventlet, fix the DNS issue, ship v2 with real realtime. It's the technically pure answer. It's also how you miss App Store review deadlines by a week.
I killed it because the user-visible payoff didn't justify the risk. At beta scale, most user sessions are solo. The optimistic-UI patterns I'd already built (snapshot state → mutate locally → notifyListeners → API → rollback on failure) made every interaction feel instant. A vote count that ticks up "when someone else votes" doesn't happen at beta scale because there IS no someone else, most of the time.
What I shipped
Optimistic UI everywhere. Save/unsave, join/leave, like/dislike, vote/unvote, comment count, member count. All local-first, all rollback-on-API-failure. Users don't see spinners; they see immediate state changes.
Backend parked at gthread worker. No monkey-patching, no Socket.IO in the connection pool. Backend stays healthy under load because gthread is boring and predictable.
Deferral doc. Written into the release playbook: what realtime would add, what it costs, and the specific fire-condition that would re-open the decision.
The fire-condition (this is the important part)
Realtime becomes user-visible when more than 3 users interact with the same poll or petition within 1 minute. Below that, no user sees a "someone else's" number change on their screen. Above that, optimistic UI starts feeling stale to whichever user isn't the actor.
When Voice's active-user telemetry shows a rolling 7-day median of >3 concurrent actors per entity, I re-open realtime. Until then, the optimistic-UI bet holds.
What I'd measure
The rolling median of concurrent actors per entity. If that number crosses 3 for 7 days running, my bet is wrong and I need to ship realtime. If it stays under, the bet holds and I can defer further.
What I'd do differently
I'd write the deferral doc before I made the decision, not after. Naming the fire-condition first would have forced me to justify the threshold instead of picking one that felt right in hindsight. Next time, the pre-mortem template gets the fire-condition slot before "what I killed."
Related writing: Liveness After the API (Medium) — the earlier design of Voice's realtime path, before I killed it for v2.