DEV Community

Alex Aslam
Alex Aslam

Posted on

DOM Bloat Survival Guide: Fixing Turbo Stream Memory Leaks

"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 }
Enter fullscreen mode Exit fullscreen mode

...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++
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

Pro Tip: Use @hotwired/turbo’s events to debug:

document.addEventListener("turbo:before-stream-render", () => {
  console.log("DOM before update:", document.body.innerHTML.length)
})
Enter fullscreen mode Exit fullscreen mode

3. Culprit #2: Unbounded Lists

The Problem

A live-updating feed:

turbo_stream.append "messages", partial: "message"
Enter fullscreen mode Exit fullscreen mode

...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
Enter fullscreen mode Exit fullscreen mode

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()
  }
}
Enter fullscreen mode Exit fullscreen mode

Bonus: Use morphdom for efficient updates:

turbo_stream.append "messages", partial: "message",
  content_type: "text/vnd.turbo-stream.html+morph"
Enter fullscreen mode Exit fullscreen mode

4. Culprit #3: Cached Turbo Frames

The Problem

<turbo-frame id="user-1" src="/users/1"></turbo-frame>
Enter fullscreen mode Exit fullscreen mode

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 = ""
  })
})
Enter fullscreen mode Exit fullscreen mode

Option 2: Lazy-load with expiration

<turbo-frame
  id="user-1"
  src="/users/1"
  data-expires="300" <!-- 5 minutes -->
></turbo-frame>
Enter fullscreen mode Exit fullscreen mode
// 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)
Enter fullscreen mode Exit fullscreen mode

5. The Nuclear Option: Memory Profiling

Chrome DevTools Steps

  1. Open Memory tab → Take heap snapshot
  2. Trigger 10 Turbo Stream updates
  3. Take another snapshot
  4. 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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Add a DOM node counter to your logging
  2. Check for detached elements monthly
  3. Audit event listeners

Fought DOM bloat before? Share your war story below!

Top comments (0)