"Our app was eating 4GB of RAM—all because of hidden Turbo Stream zombies."
Turbo Streams make real-time updates feel like magic. But that magic comes at a cost: memory leaks that silently bloat your app until it crashes.
After debugging a production app that restarted hourly from OOM errors, we discovered three major DOM bloat culprits—and how to fix them permanently.
1. The Invisible DOM Apocalypse
How Turbo Streams Leak Memory
Every time you:
turbo_stream.append "messages", partial: "message", locals: { message: @message }
...you add DOM nodes to the page. If those nodes aren’t cleaned up:
- Memory usage grows with every update
- Event listeners pile up
- Browser performance tanks
Symptoms
⚠️ Tab crashes after hours of use
⚠️ Scroll jank in long-lived tabs
⚠️ Turbo Drive gets slower over time
2. Culprit #1: Orphaned Event Listeners
The Problem
Stimulus controllers auto-connect to DOM elements, but never disconnect when Turbo replaces them:
// controllers/counter_controller.js
export default class extends Controller {
connect() {
this.element.addEventListener("click", this.increment)
}
increment = () => this.count++
}
Result: Every Turbo Stream append creates duplicate listeners.
The Fix
export default class extends Controller {
connect() {
this.element.addEventListener("click", this.increment)
}
disconnect() { // 👈 New!
this.element.removeEventListener("click", this.increment)
}
}
Pro Tip: Use @hotwired/turbo
’s events to debug:
document.addEventListener("turbo:before-stream-render", () => {
console.log("DOM before update:", document.body.innerHTML.length)
})
3. Culprit #2: Unbounded Lists
The Problem
A live-updating feed:
turbo_stream.append "messages", partial: "message"
...will grow infinitely unless you prune old items.
The Fix
Option 1: Server-side pruning
# Only keep last 50 messages
if @message.save
Message.where(room_id: @room.id).order(created_at: :desc).offset(50).delete_all
turbo_stream.append "messages", partial: "message"
end
Option 2: Client-side cleanup
// In a Stimulus controller
pruneOldItems() {
const container = this.element
const items = container.querySelectorAll(".message")
if (items.length > 50) {
items[0].remove()
}
}
Bonus: Use morphdom
for efficient updates:
turbo_stream.append "messages", partial: "message",
content_type: "text/vnd.turbo-stream.html+morph"
4. Culprit #3: Cached Turbo Frames
The Problem
<turbo-frame id="user-1" src="/users/1"></turbo-frame>
If the user navigates away, the frame stays in memory with all its event listeners.
The Fix
Option 1: Explicit disposal
// When leaving a page
document.addEventListener("turbo:before-visit", () => {
document.querySelectorAll("turbo-frame").forEach(frame => {
frame.innerHTML = ""
})
})
Option 2: Lazy-load with expiration
<turbo-frame
id="user-1"
src="/users/1"
data-expires="300" <!-- 5 minutes -->
></turbo-frame>
// Auto-remove expired frames
setInterval(() => {
document.querySelectorAll("turbo-frame[data-expires]").forEach(frame => {
if (Date.now() - frame.lastLoadedAt > frame.dataset.expires * 1000) {
frame.remove()
}
})
}, 60_000)
5. The Nuclear Option: Memory Profiling
Chrome DevTools Steps
- Open Memory tab → Take heap snapshot
- Trigger 10 Turbo Stream updates
- Take another snapshot
- Filter for "Detached" elements
Rails Helper
# Log DOM node counts
module Turbo::Streams::TagBuilder
def append(*args)
Rails.logger.info "DOM nodes before: #{`document.querySelectorAll('*').length`}"
super
Rails.logger.info "DOM nodes after: #{`document.querySelectorAll('*').length`}"
end
end
6. Prevention Checklist
✅ Always implement disconnect()
in Stimulus
✅ Prune old Turbo Stream items (client or server)
✅ Avoid permanent Turbo Frames
✅ Profile memory in long-running tabs
"But Our App Doesn’t Use Turbo Streams!"
Start monitoring anyway:
- Add a DOM node counter to your logging
- Check for detached elements monthly
- Audit event listeners
Fought DOM bloat before? Share your war story below!
Top comments (0)