When building a Rails application with Turbo, I ran into a subtle issue: flash messages wouldn't show up after Turbo Frame requests. Full page loads and Turbo Stream responses worked fine, but frame updates silently swallowed them.

Here's the problem and a generic ~15-line fix.

How Turbo processes responses

Turbo handles three types of responses differently:

Full page loads render the entire HTML document, including any flash messages baked into the layout. No issues here.

Turbo Stream responses (text/vnd.turbo-stream.html) are processed as a list of DOM mutation commands: update, replace, append, etc. If you wrap your response in a layout that includes a turbo_stream.update "flash" action, it just works.

Turbo Frame responses are where things get interesting. When Turbo makes a frame request, it receives a full HTML response but only extracts the matching <turbo-frame> element. Everything else in the response, including any <turbo-stream> elements, is discarded.

The setup

In my application, I use a Rails layout that injects a flash update into every Turbo response:

<%= turbo_stream.update "flash" do %>
  <% flash.each do |type, message| %>
    <%= render FlashNotification.new(type:) { message } %>
  <% end %>
<% end %>

<%= yield %>

The controller selects this layout for all Turbo requests:

layout -> { turbo_request? ? "turbo_stream_with_flash" : false }

This works perfectly for Turbo Stream responses. But for frame requests, the <turbo-stream action="update" target="flash"> element is right there in the HTML response. Turbo just ignores it because it only cares about the matching frame.

The default behaviour if no layout is specified will render "turbo_rails/frame" for Turbo Stream responses, so my approach just extends the default minimal layout.

To determine whether a request will be processed by turbo, I added the following helpers. There might be more elegant ways, but this works pretty well:

def turbo_stream_request?
  request.headers["x-turbo-request-id"].present?
end

def turbo_frame_request?
  request.headers["turbo-frame"].present?
end

def turbo_request?
  turbo_stream_request? || turbo_frame_request?
end

The fix

The <turbo-stream> elements are already in the response. We just need to tell Turbo to process them. 15 lines of JavaScript:

import { Turbo } from "@hotwired/turbo-rails";

document.addEventListener("turbo:before-fetch-response", async (event) => {
  const response = event.detail.fetchResponse.response;
  const contentType = response.headers.get("content-type") || "";

  // Only process HTML responses (frame requests).
  // Turbo stream responses are already handled natively.
  if (!contentType.includes("text/html")) return;

  const html = await response.clone().text();
  if (!html.includes("<turbo-stream")) return;

  const doc = new DOMParser().parseFromString(html, "text/html");
  const streams = doc.querySelectorAll("turbo-stream");

  if (streams.length > 0) {
    const streamHTML = Array.from(streams)
      .map((s) => s.outerHTML)
      .join("");
    Turbo.renderStreamMessage(streamHTML);
  }
});

Import it once in your application entrypoint and you're done.

How it works

Turbo fires turbo:before-fetch-response for every fetch it makes. The listener:

  1. Checks if the response is HTML (not a Turbo Stream response, which has its own content type)
  2. Clones the response with .clone(). This is necessary because a Response body can only be read once. Without cloning, our read would consume the body and Turbo wouldn't be able to read it again for its normal frame processing.
  3. Parses the HTML and looks for <turbo-stream> elements
  4. If found, feeds them to Turbo.renderStreamMessage(), the same API Turbo uses internally (and that I already used in other parts of the application)

Turbo then continues its normal processing: extracting the matching frame from the response. The flash update happens in parallel.

Why throw more javascript at the problem?

I considered a few alternatives:

Embedding turbo stream actions inside each turbo frame tag works, but means repeating the flash update logic in every single frame across the application. Even with a helper, it's unnecessary coupling.

Custom response headers: serialize flash messages into an HTTP header, parse them client-side. This adds complexity on both ends, headers have size limits, and you'd need even more client-side rendering logic for the flash notifications.

Changing everything to full turbo stream responses: That would mean I could no longer turbo's implicit frame selection (update the closest ancestor of the form/link that is a frame). I would have to specify which elements to update for each request and still write a helper to also include the flash message update.

A separate fetch for flash messages: an extra HTTP request after every frame navigation.

The event listener approach is pretty generic and doesn't add a lot of overhead. It doesn't just fix my flash message problem, any <turbo-stream> action embedded in a frame response will be processed automatically.