RUBY ON RAILS · 17 MIN READ ·

Claude-antwoorden Streamen in Rails: SSE, Turbo Streams en Real-Time AI-chat

Stream Claude-antwoorden in Rails met SSE en Turbo Streams. Token-voor-token AI-chat, backpressure, reconnects en productiepatronen die schalen.

Claude-antwoorden Streamen in Rails: SSE, Turbo Streams en Real-Time AI-chat

Een SaaS-oprichter liet me in september een demo zien van zijn nieuwe AI-assistent. Hij drukte op submit, de pagina bevroor elf seconden, en daarna verscheen het hele antwoord ineens. Hij keek me aan en zei: “Het is prima, toch? Elf seconden is snel voor een LLM.” Het was niet prima. Iedere concurrent waar hij tegenop bokste had een chat-UI die binnen 400ms begon te typen, en zijn klanten klikten de tab weg voordat zijn antwoord rende. We hebben de middag erop zijn enkele blokkerende controller-actie omgebouwd naar een streaming endpoint dat Claude-antwoorden streamt in Rails via Server-Sent Events naar Turbo Streams. De tijd tot het eerste token zakte naar 380ms. De trial-to-paid conversie op de AI-feature verdubbelde de maand erna.

Na negentien jaar Rails heb ik veel langlopende responses uitgeleverd — van grote CSV-exports tot Postgres COPY-pipelines — en geen van die patronen heeft zo’n scherp UX-probleem als een LLM-call. Een SQL-query van twee seconden voelt acceptabel. Twee seconden wachten op een AI-antwoord voelt kapot. Deze post is het productiepatroon dat ik nu inzet bij elke Rails-app die met Claude praat: hoe je tokens uit de Anthropic API naar de browser streamt, ze rendert via Turbo Streams, en de verbinding levend houdt door proxies, timeouts en reconnects heen.

Waarom Claude-antwoorden Streamen in Rails Ertoe Doet

Een niet-gestreamde Claude-call in Rails ziet er bedrieglijk eenvoudig uit. Je roept de API aan, wacht op het volledige antwoord, rendert. Het probleem is dat zelfs een snelle Sonnet 4.6-respons die in drie seconden klaar is voelt als een hang, omdat er drie seconden lang niets verandert op het scherm. Gebruikers zijn door ChatGPT en Claude.ai getraind om tekens te zien verschijnen terwijl het model ze genereert. Alles trager voelt kapot.

Streaming lost drie dingen tegelijk op: gevoelde latency, fouttolerantie en kostentransparantie. De tijd tot het eerste token blijft meestal onder 500ms, zelfs voor lange antwoorden. Als het model er bij token 200 naast zit, ziet je gebruiker het en kan hij annuleren in plaats van wachten op de volledige 2000 tokens. En de geleidelijke onthulling communiceert vanzelf “dit wordt nu gegenereerd,” wat de juiste verwachtingen schept.

De Rails-specifieke uitdaging is dat streaming een HTTP-verbinding voor de duur van de response open moet houden. Dat botst met de meeste defaults van Rails — request middleware buffert de body, Puma-workers zijn kostbaar, en je reverse proxy buffert responses waarschijnlijk ook. Het patroon dat ik hieronder uitwerk loopt door al die lagen.

De Anthropic Streaming API in Ruby

Anthropic’s Messages API ondersteunt streaming via stream: true, wat de response omschakelt van één JSON-body naar een stroom Server-Sent Events. De officiële anthropic Ruby SDK ontsluit dit met een block-based API.

require "anthropic"

client = Anthropic::Client.new(api_key: ENV.fetch("ANTHROPIC_API_KEY"))

client.messages.stream(
  model: "claude-sonnet-4-6",
  max_tokens: 2048,
  messages: [
    { role: "user", content: "Explain Rails Server-Sent Events in three paragraphs." }
  ]
) do |event|
  case event.type
  when "content_block_delta"
    print event.delta.text
  when "message_stop"
    puts "\n--- done ---"
  end
end

De SDK yieldt getypeerde events voor elke fase: message_start, content_block_start, content_block_delta (de daadwerkelijke tokens), content_block_stop, message_delta (met usage-info) en message_stop. In 90% van de gevallen gebruik je alleen content_block_delta voor de tekst en message_stop om te weten wanneer je het kanaal kunt sluiten.

Als je het Anthropic prompt caching-patroon gebruikt met gecachte system prompts, werkt streaming exact hetzelfde — de cache hit verschijnt in het uiteindelijke message_delta usage-event, maar het streaming-gedrag verandert niet. Combineer streaming altijd met caching in productie; je wilt zowel de latency-winst als de kostenwinst.

SSE Inhaken Op Rails Met ActionController::Live

Rails levert al sinds versie 4 streaming responses via ActionController::Live. Dit is het stuk dat de meeste tutorials missen: mensen pakken ActionCable of een aparte Node-service, terwijl simpele Live controllers meestal volstaan.

class ChatStreamsController < ApplicationController
  include ActionController::Live

  def create
    response.headers["Content-Type"] = "text/event-stream"
    response.headers["Cache-Control"] = "no-cache"
    response.headers["X-Accel-Buffering"] = "no"
    response.headers["Connection"] = "keep-alive"

    sse = SSE.new(response.stream, retry: 3000, event: "delta")
    conversation = Current.user.conversations.find(params[:conversation_id])
    message = conversation.messages.create!(role: "user", content: params[:prompt])
    assistant_message = conversation.messages.create!(role: "assistant", content: "")

    client = Anthropic::Client.new(api_key: ENV.fetch("ANTHROPIC_API_KEY"))

    client.messages.stream(
      model: "claude-sonnet-4-6",
      max_tokens: 2048,
      system: conversation.system_prompt,
      messages: conversation.api_messages
    ) do |event|
      case event.type
      when "content_block_delta"
        assistant_message.append_text!(event.delta.text)
        sse.write({ message_id: assistant_message.id, delta: event.delta.text })
      when "message_stop"
        sse.write({ message_id: assistant_message.id, done: true }, event: "done")
      end
    end
  rescue ActionController::Live::ClientDisconnected
    Rails.logger.info("Client disconnected mid-stream for message #{assistant_message&.id}")
  rescue Anthropic::APIError => e
    sse.write({ error: e.message }, event: "error")
  ensure
    sse&.close
    response.stream.close
  end
end

Een paar details die ertoe doen in productie:

De header X-Accel-Buffering: no vertelt nginx dat hij de response niet moet bufferen. Zonder die header blijven je tokens in de proxy-buffer hangen tot er genoeg zijn opgehoopt om een flush te rechtvaardigen — en dat verslaat het hele doel. Dezelfde instelling geldt voor Caddy (flush_interval -1) en de meeste cloud load balancers.

De append_text!-methode doet het werk van het incrementeel persistent maken van het assistant-bericht. Ik implementeer hem als een dunne wrapper die de content-kolom bijwerkt en doorbroadcastet naar andere listeners — zo ziet een gebruiker met de conversatie open in een tweede tab het bericht ook groeien. Dat gebruiken we in de volgende sectie.

De rescue voor ClientDisconnected is essentieel. Sluit een gebruiker midden in de stream zijn tab, dan wil je geen stack trace; je wilt het loggen en stoppen. De Anthropic::APIError-rescue vangt zaken als rate limits en stuurt ze door naar de client.

De Stream Verbinden Met Turbo Streams in de UI

Server-Sent Events op zichzelf zijn bruikbaar — de native EventSource-API van de browser kan ze ontvangen — maar het zelf in de DOM stitchen is precies de soort code die je niet wilt onderhouden. Turbo Streams weet al hoe het DOM-operaties vanuit de server moet toepassen, dus laat ik de controller direct Turbo Stream-acties uitsturen.

Er zijn twee patronen. Voor app-brede chatstate die elke tab moet zien, gebruik je ActionCable-broadcasts via Turbo::StreamsChannel. Voor een enkel in-flight verzoek van één gebruiker gebruik je een dedicated SSE-endpoint dat Turbo Stream-frames stuurt. Voor chat heb ik een voorkeur voor de tweede aanpak: je betaalt geen ActionCable-overhead en de levenscyclus van de stream valt exact samen met die van het verzoek.

class ChatStreamsController < ApplicationController
  include ActionController::Live
  include Turbo::Streams::ActionHelper

  def create
    response.headers["Content-Type"] = "text/event-stream"
    response.headers["X-Accel-Buffering"] = "no"

    conversation = Current.user.conversations.find(params[:conversation_id])
    assistant_message = conversation.messages.create!(role: "assistant", content: "")

    response.stream.write(turbo_stream_action_tag(
      "append",
      target: "messages",
      template: render_to_string(partial: "messages/message", locals: { message: assistant_message })
    ))

    Anthropic::Client.new.messages.stream(stream_params(conversation)) do |event|
      next unless event.type == "content_block_delta"

      assistant_message.append_text!(event.delta.text)
      response.stream.write(turbo_stream_action_tag(
        "append",
        target: "message_#{assistant_message.id}_content",
        template: ERB::Util.html_escape(event.delta.text)
      ))
    end
  ensure
    response.stream.close
  end
end

Aan de client-kant heb je een kleine Stimulus-controller nodig (zie Stimulus controllers productiepatronen voor het bredere patroon) die de SSE-verbinding opent en de Turbo Stream-frames doorgeeft aan Turbo’s renderStreamMessage.

import { Controller } from "@hotwired/stimulus"
import { renderStreamMessage } from "@hotwired/turbo"

export default class extends Controller {
  static values = { url: String }

  connect() {
    this.source = new EventSource(this.urlValue)
    this.source.onmessage = (event) => renderStreamMessage(event.data)
    this.source.addEventListener("done", () => this.source.close())
    this.source.addEventListener("error", () => this.source.close())
  }

  disconnect() {
    this.source?.close()
  }
}

Dat is de volledige pipeline. De gebruiker typt, het formulier post een prompt, de controller opent een stream, Claude-tokens stromen terug, elke content_block_delta wordt een Turbo Stream append-actie, de browser zet de tekst in een live <div>. De tijd tot het eerste zichtbare teken zit in productie onder 500ms.

Backpressure, Reconnects en Onvolledige Berichten

De naïeve versie breekt op drie plekken zodra je hem live zet.

Backpressure ontstaat wanneer de client traag is met lezen maar de server doorschrijft. De TCP-buffer loopt vol, je Puma-thread blokkeert op de write, en één trage gebruiker pint een worker vast. De fix is een write timeout op response.stream zetten en de stream afbreken zodra hij overschreden wordt. De block-based API van de Anthropic SDK speelt hier goed mee, want raisen binnen het block sluit ook de upstream-connectie netjes af.

response.stream.instance_variable_set(:@write_timeout, 5)

Reconnects doen ertoe omdat mobiele netwerken wegvallen. De EventSource-API reconnect automatisch en stuurt de header Last-Event-ID mee zodat je kunt hervatten. De schoonste manier om dit te ondersteunen is om in elk SSE-frame de positie in content van het assistant-bericht mee te sturen, en bij reconnect te hervatten vanaf die offset op basis van de persistente bericht-content — niet door Claude opnieuw aan te roepen.

def create
  last_event_id = request.headers["Last-Event-ID"].to_i
  message = conversation.messages.find(params[:message_id])

  if last_event_id < message.content.length
    sse.write({ delta: message.content[last_event_id..] }, id: message.content.length)
  end

  if message.completed?
    sse.write({ done: true }, event: "done")
    return
  end

  # otherwise tail the message until it's complete
  follow_in_progress_message(message, sse)
end

Daarom persist het append_text!-patroon tokens terwijl ze binnenkomen — het maakt het assistant-bericht herstelbaar. Zonder persistence betekent een reconnect dat je Anthropic opnieuw factureert voor tokens die de gebruiker al gezien heeft. Met persistence zijn reconnects effectief gratis.

Onvolledige berichten doen zich voor wanneer Claude midden in een zin wordt afgekapt door een max_tokens-limiet, een netwerkfout of een wegnavigerende gebruiker. Je hebt een completed?-flag op het bericht nodig om “nog aan het streamen” van “klaar” en “verlaten” te kunnen onderscheiden. Ik gebruik drie staten — streaming, completed, failed — en een achtergrondjob die streaming-berichten van ouder dan vijf minuten opruimt.

Productie-valkuilen

Een handvol operationele details die de demo-video’s nooit noemen:

Puma threads. Streaming houdt een thread vast voor de duur van de response. Als een doorsnee Claude-respons 8 seconden duurt en je hebt 5 threads per worker, is je effectieve concurrency voor streaming-endpoints 5. Dimensioneer juist door streaming-controllers te isoleren achter een eigen Puma worker pool — of beter: een aparte Puma-binding voor ChatStreamsController met meer threads en een lagere max requests. Het onderliggende tuning-model staat in Rails Puma tuning.

Cloud load balancers. AWS ALB heeft een default idle timeout van 60 seconden. Duurt Claude langer en is er geen verkeer, dan sluit de LB de verbinding. Houd de verbinding open met een heartbeat-comment elke 15 seconden (response.stream.write(": keepalive\n\n")) of zet de idle timeout op 300 seconden.

Rails reloading. In development kan code reloading langlopende streaming requests laten deadlocken. Wikkel de actie in Rails.application.executor.wrap zodat de executor weet dat de dependencies geladen moeten blijven voor de duur van de stream. De standaard ActionController::Live doet hier een deel van, maar niet alles.

Logging. Streaming responses maken Rails’ standaard request logging onbruikbaar omdat het verzoek pas “eindigt” als de stream sluit. Voeg een before_action toe die de starttijd vastlegt en een after_action die de werkelijke duur, het tokenaantal en de finish reason logt. Die data wil je hebben als je debugt waarom één gebruiker een stream van 47 seconden had.

Testen. Gebruik request_via_redirect-stijl tests spaarzaam met streaming; die blijven graag hangen. Schrijf in plaats daarvan integration-tests die de Anthropic-client mocken op SDK-niveau, een vooraf bepaalde reeks events yielden, en asserten op de gestreamde body-chunks.

FAQ

Moet ik ActionCable in plaats van SSE gebruiken om Claude-antwoorden in Rails te streamen?

Voor single-user, single-conversation streaming kies ik SSE omdat het simpeler is — één HTTP-verzoek, één stream, automatische browser-reconnects, geen aparte WebSocket-server, geen kanaal-abonnementen om te beheren. ActionCable is de juiste keuze als je multi-user broadcasts hebt (een gedeelde collaborative AI-chat) of WebSockets toch al voor andere features gebruikt. Met SolidCable in Rails 8 is de operationele overhead van ActionCable veel lager geworden, maar SSE blijft minder code voor de chat-use-case.

Hoe ga ik om met Claude tool use en function calling tijdens streaming in Rails?

Tool use compliceert streaming omdat het model tool_use-blokken uitstuurt die uitgevoerd moeten worden voordat de respons doorgaat. Het patroon: stream tot je een content_block_start ziet met type: "tool_use", verzamel de tool-input, voer de tool uit zodra content_block_stop voor dat blok aankomt, en roep dan opnieuw messages.stream aan met het tool-resultaat toegevoegd aan de conversatie. Dat is dezelfde loop uit Rails AI agents met Claude tool use, met streaming om elke LLM-call heen.

Kan ik Anthropic streaming gebruiken met prompt caching in Rails?

Ja — ze combineren netjes. Zet cache_control op je system prompt en eventuele grote context-blokken precies zoals je het bij een niet-gestreamde call zou doen. Het slot-message_delta-event van de streaming-respons bevat usage.cache_read_input_tokens, en daarmee verifieer je dat de cache hit. In productie zie ik streaming + caching samen zowel de p95-latency als de kosten per conversatie met 70 tot 90 procent omlaag brengen op chat-apps met lange system prompts.

Wat gebeurt er als een gebruiker de browser-tab sluit terwijl een Claude-respons aan het streamen is?

Rails raised ActionController::Live::ClientDisconnected bij de volgende write naar response.stream. De block-based API van de Anthropic SDK blijft events yielden tot je het block verlaat, maar je kunt er schoon uitbreken — en de upstream HTTP-verbinding naar Anthropic sluit dan, zodat er geen tokens meer worden gegenereerd. Is het assistant-bericht incrementeel persistent gemaakt, dan is de conversatie-geschiedenis nog intact. Zo niet, dan ben je de partiële respons kwijt. Persist altijd terwijl je streamt.

Hulp nodig bij het uitleveren van een productiewaardige AI-chat in Rails? TTB Software is gespecialiseerd in Rails, Claude-integraties en de operationele patronen die streaming-endpoints op schaal betrouwbaar houden. We doen dit al negentien jaar.

#rails-streaming-claude #anthropic-streaming-ruby #server-sent-events-rails #turbo-streams-ai-chat #ai-chat-rails #claude-api-ruby #rails-actioncontroller-live

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software