How does Turbo listen for Turbo Streams?

Ben about to raise hell

Turbo Stream elements are custom HTML elements which change the DOM when they are added to it. A <turbo-stream> tag can, by specifying an action attribute, trigger one of seven “DOM changing” actions: append, prepend, replace, update, remove, before and after. For example, a user lands on a page with a list of Books and sees a form to create a new book. They fill out the form and submit, and the server responds with a <turbo-stream action="append"> element which contains within it the entry for the newly created book. Turbo sees that and adds it to the DOM, which triggers the relevant append stream action and adds the new book HTML to the page.

That’s really cool, but it raises a few questions. First, when the form is submitted, how is Turbo telling our server to send us back one or more Turbo Stream elements? And second, when the server does respond back with one or more Turbo Stream elements, how does Turbo know that it should add them to the DOM?

Let’s try to peer behind the curtain and figure out the answer to these questions. Doing so has a couple of important benefits:

How does Turbo handle form submissions?

When Turbo starts up in the user’s browser, it adds an event listener to document, and listens for the submit event. When any form in the user’s browser is submitted, and that submission meets a few criteria (e.g. the submission does not happen in an iframe) this listener asks Turbo Drive to submit the form. Turbo Drive, via its Navigator class, initiates the form submission. Form submission in Turbo Drive amounts to a two step process:

  1. First, Turbo Drive calls preventDefault on the submit event. This tells the browser not to submit the form.
  2. Then, Turbo sends the form data to the server using the fetch API.

The server thus receives the same data it would have received had Turbo not intercepted the submission. There is one important difference however; to the “Accept” request header, Turbo adds the content/MIME type: text/vnd.turbo-stream.html. This tells the server that it can respond with <turbo-stream> elements. The server, in turn, sends the Content-Type: text/vnd.turbo-stream.html header back to Turbo if it is responding with Turbo Stream elements.

If the server doesn’t want to respond to a successful submission with Turbo Streams, then, according to the Turbo Handbook, it should issue a redirect. If not, Turbo will throw an error. On the other hand, if it wants to respond to an invalid or erroneous request with plain old HTML (containing validation error messages, presumably), it can do so and Turbo will render that on the current page.

How does Turbo handle Turbo Stream responses?

When Turbo starts up, it attaches a listener to the turbo:before-fetch-response event. The listener is called (as of August 2025) StreamObserver#inspectFetchResponse.

After Turbo makes the fetch request and retrieves the response, it dispatches the turbo:before-fetch-response event, attaching the response data to it. Dispatching an event in JavaScript causes all attached listeners to be executed in order synchronously, and thus inspectFetchResponse is executed.

inspectFetchResponse first checks if the response is a Turbo Stream response (it is if its Content-Type is text/vnd.turbo-stream.html) and if so, adds the response to the DOM. As we learned in my article about Turbo Streams, when <turbo-stream> elements are added to the DOM, it triggers custom JavaScript code that runs and performs the action embedded in each stream (“append”, “prepend”, “remove” etc). The cool thing about this is that the server can respond with multiple <turbo-stream> elements in a single response, and all actions will be performed one after the other.

Very naively (without bothering with event dispatching), we can implement this behavior like so:

// Listen for all form submit events
document.addEventListener("submit", async (event) => {
  event.preventDefault();

  const form = event.target;
  const url = form.action;
  const method = form.method;

  const response = await fetch(url, {
    method,
    headers: {
      Accept: "text/vnd.turbo-stream.html",
    },
    body: new FormData(form),
    // and other important stuff like credentials and CSRF token
  });

  if (response.headers.get("content-type") === "text/vnd.turbo-stream.html") {
    const turboStreamContent = await response.text();
    // Adding stream elements to the DOM triggers turbo stream actions!
    document.body.insertAdjacentHTML("beforeend", turboStreamContent);
  } else {
    // other logic to handle HTML responses
  }
});

What about a regular fetch request?

To get your server to send you back Turbo Streams when making a regular old fetch request, you’ll need to update the Accept header yourself. In Rails, this is convenient to do with the @rails/request library. What’s more, this library will even automatically add Turbo Stream responses to the DOM.

import { patch } from "@rails/request.js"
...
updateBook(bookId, book) {
  patch(`my-app.com/books/${bookId}`, {
    body: JSON.stringify({ book }),
    responseKind: "turbo-stream",
  })
}

Pretty convenient, huh?

Setting the responseKind to turbo-stream is important because it adds the text/vnd.turbo-stream.html MIME type to the request’s Accept header. That lets your server’s respond_to block know to render Turbo Streams.

What happens if the server responds with plain old HTML to a successful Turbo stream form submission?

In this case, Turbo will raise an error. If you want to render HTML, you have to issue a redirect. This seems to be a philosophical choice of sorts. Responding with HTML to a successful form submission conventionally implies a new page (and potentially a new path), so Turbo wants you to redirect to it instead of rendering it in place. If you want to render changes in place, then Turbo wants you to use Turbo Stream elements.

Conclusion

Conceptually, the Turbo Stream “form submission flow” is quite simple. Turbo first intercepts submissions and adds a header telling the server to respond with Turbo Streams. The successful server response is then added to the DOM. Finally, the pre-defined custom element behavior of each Turbo Stream runs automatically, making app updates visible to the user.

If you got value out of this article and want to hear more from me, consider subscribing to my email newsletter to get notified whenever I publish a new article.

Want to be notified when I publish a new article?