When the "simple" solution isn't simple
Last week I added real-time updates using Turbo Streams. It worked! When someone updated an inspection, everyone viewing it saw changes instantly. Magic.
Except it wasn't magic. It was HTML fragments flying through WebSocket connections, and it was starting to cause weird problems.
This week I ripped it out and rebuilt it properly using ActionCable + Alpine.js. Less magic, more control, much better architecture.
The Turbo Streams problem
Turbo Streams sounds brilliant on paper: broadcast HTML fragments from the server, replace DOM elements automatically, done. Real-time updates without writing JavaScript.
In practice? It created subtle issues that drove me nuts:
State loss: Alpine.js components maintain client-side state (form inputs, expanded sections, scroll positions). When Turbo Streams replaced HTML, that state vanished. User typing an observation? Gone when update broadcast arrived.
Debugging nightmares: Something not updating correctly? Good luck debugging HTML strings in WebSocket messages. No structure, just HTML fragments that may or may not match your DOM.
Fragile coupling: Server-side partials needed exact DOM structure matching client expectations. Change a class name in a partial? Better update the Turbo Stream broadcast too, or things break mysteriously.
Bandwidth waste: Sending entire HTML fragments for simple data changes. Updating one field? Here's 2KB of HTML. Seventy percent of the WebSocket traffic was unnecessary markup.
It worked, but it felt fragile. And when you're building software people rely on, fragile isn't acceptable.
The simpler (actually simple) approach
This week I replaced Turbo Streams with a cleaner architecture: ActionCable broadcasts JSON describing what changed, Alpine.js components receive that JSON and update themselves.
Instead of:
Broadcast HTML: "<div class='status'>Completed</div>"
Replace DOM element with ID "status-123"
Now:
Broadcast JSON: {"status": "completed", "id": 123}
Alpine.js component receives message, updates its reactive state
DOM updates automatically through Alpine's reactivity
Way cleaner. Clear separation: ActionCable handles transport, Alpine.js handles presentation, server focuses on business logic.
And the benefits are real:
State persists: Alpine components control their own rendering. WebSocket message updates the data, but form inputs, scroll positions, expanded sections—all preserved.
Debugging makes sense: JSON messages in WebSocket inspector show exactly what changed. No parsing HTML strings to figure out what happened.
Smaller payloads: Sending structured data instead of HTML. That 2KB HTML fragment? Now 50 bytes of JSON. Seventy percent bandwidth reduction.
Flexible rendering: Client decides how to display data. Want to show status differently on mobile? Change Alpine template, server doesn't care.
The search improvement nobody noticed
While refactoring real-time updates, I also made property search smarter.
Previously: search for "victoria" and you'd get alphabetical results. Victoria Road flat 1, Victoria Road flat 2, Victoria Street house...
Now: relevance-based ranking. Exact reference matches appear first. Properties with query terms in headlines rank higher. Recently updated properties get priority. Phonetic matching catches typos ("viktoria" finds "Victoria").
Does it use machine learning? No. Does it feel smart? Yes. Because it ranks results how people expect, not how databases naturally sort.
The technical migration (carefully)
You can't just rip out working real-time updates and hope the replacement works. Needed careful migration:
- Built new ActionCable channels alongside existing Turbo Streams
- Updated Alpine components to handle both message types during transition
- Switched one feature at a time (inspections first, then properties)
- Monitored production for issues
- Once stable, removed old Turbo Streams code
The gradual rollout meant if something broke, I could roll back one feature without breaking everything. Boring? Yes. Saved me from a 2am emergency debugging session? Also yes.
The bugs that appeared in production
Of course there were bugs. There are always bugs.
PDF downloads broken: URLs worked in development, failed in production with multi-tenant subdomains. Root cause: signed S3 URLs using wrong domain. Fixed with proper subdomain handling.
Email race conditions: Two staff trying to send emails simultaneously caused weird failures. Fixed with proper job ordering and idempotency keys.
Pagination weirdness: Alpine.js was reusing DOM elements with duplicate keys causing wrong pages to highlight. Fixed with unique key generation.
Each bug taught me something about how the system actually gets used in production versus how I thought it would be used.
What agencies actually get
If you're running a letting agency, here's what this week means for you:
More reliable real-time updates. Faster, smaller, less likely to lose your work when updates arrive.
Better search. Type a property reference or address and relevant results appear first, not buried alphabetically.
Fewer weird state issues. Form inputs don't disappear. Scroll positions don't jump. Things just work.
Gmail integration that's solid. Last week's Gmail OAuth now actually works reliably for sending inspection reports.
Honestly, most of this week's work is invisible. Which is good—you shouldn't notice infrastructure improvements, you should just notice things working better.
What I learned
This week taught me that sometimes you need to rebuild something that "works" because it doesn't work well enough.
Turbo Streams worked. Real-time updates happened. But the architecture was brittle, debugging was painful, and state loss was annoying users.
The ActionCable + Alpine.js approach is more code. More explicit. Less "magic." But it's also more reliable, more debuggable, and more maintainable.
Early in a project, magic is fine—ship features fast, learn what users need. But once you know what you're building, remove the magic and build proper architecture.
Also: gradual migrations are boring but safe. Rewriting everything at once is exciting but risky. I chose boring. My sleep schedule thanks me.
Next week: hopefully building new features instead of refactoring old ones. But at least the foundation is solid now.