Streaming Response con Claude API: Real-Time Output in JavaScript

Lo streaming è la differenza tra un chatbot che sembra “pensare” in tempo reale e uno che ti fa aspettare 10 secondi una risposta monolitica. Con Claude API lo streaming è semplice da implementare, ma ci sono dettagli importanti sulla gestione degli eventi SSE, sull’integrazione con React e sulla cancellazione dello stream che vale la pena capire bene prima di andare in produzione.

In questo tutorial partiremo dall’implementazione base in Node.js fino a una chat React completa con aggiornamento token per token e abort controller.

Perché usare lo streaming

Senza streaming, la tua applicazione invia la richiesta e aspetta che il modello generi l’intera risposta prima di restituire qualsiasi cosa. Per risposte lunghe — analisi di codice, spiegazioni, generazione di testo — questo può richiedere 10-20 secondi di schermata vuota. Con lo streaming, i token arrivano man mano che vengono generati, tipicamente entro 200-300ms dal primo token. La latenza percepita crolla e l’esperienza utente migliora drasticamente.

Setup base con Anthropic SDK

Installa l’SDK se non l’hai già:

npm install @anthropic-ai/sdk

La versione streaming più semplice usa stream() invece di messages.create():

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

async function streamResponse(prompt: string) {
  const stream = await client.messages.stream({
    model: "claude-opus-4-6",
    max_tokens: 1024,
    messages: [{ role: "user", content: prompt }],
  });

  for await (const chunk of stream) {
    if (
      chunk.type === "content_block_delta" &&
      chunk.delta.type === "text_delta"
    ) {
      process.stdout.write(chunk.delta.text);
    }
  }

  const finalMessage = await stream.finalMessage();
  console.log("

Stop reason:", finalMessage.stop_reason);
  console.log("Total tokens:", finalMessage.usage.input_tokens + finalMessage.usage.output_tokens);
}

streamResponse("Spiega il pattern Observer in JavaScript con un esempio pratico.");

Tipi di eventi SSE

Lo stream di Claude emette diversi tipi di eventi. Capirli è fondamentale per gestire correttamente tutti i casi d’uso:

message_start — arriva per primo, contiene i metadati del messaggio (id, model, usage iniziale). content_block_start — segnala l’inizio di un blocco di contenuto (text o tool_use). content_block_delta — è il più frequente: porta i token di testo (text_delta) o i chunk JSON per i tool (input_json_delta). content_block_stop — segnala la fine del blocco corrente. message_delta — arriva verso la fine con stop_reason e l’usage finale aggiornato. message_stop — lo stream è terminato.

// Gestione esplicita di tutti gli eventi
const stream = client.messages.stream({
  model: "claude-opus-4-6",
  max_tokens: 1024,
  messages: [{ role: "user", content: prompt }],
});

stream.on("message_start", (event) => {
  console.log("Stream avviato, message_id:", event.message.id);
});

stream.on("text", (text) => {
  // Shortcut: emette solo i delta di testo
  process.stdout.write(text);
});

stream.on("message", (message) => {
  // Messaggio finale completo
  console.log("
Stop reason:", message.stop_reason);
});

stream.on("error", (error) => {
  console.error("Stream error:", error);
});

await stream.finalMessage();

Integrazione React con aggiornamento UI token per token

Per un chatbot React, lo streaming deve passare attraverso un endpoint API del tuo backend (mai esporre la chiave API al frontend) e poi essere consumato lato client tramite fetch con la lettura del ReadableStream.

Backend (Next.js App Router — app/api/chat/route.ts):

import Anthropic from "@anthropic-ai/sdk";
import { NextRequest } from "next/server";

const client = new Anthropic();

export async function POST(req: NextRequest) {
  const { messages } = await req.json();

  const encoder = new TextEncoder();
  const readable = new ReadableStream({
    async start(controller) {
      const stream = client.messages.stream({
        model: "claude-opus-4-6",
        max_tokens: 2048,
        messages,
      });

      for await (const chunk of stream) {
        if (
          chunk.type === "content_block_delta" &&
          chunk.delta.type === "text_delta"
        ) {
          controller.enqueue(encoder.encode(chunk.delta.text));
        }
      }
      controller.close();
    },
  });

  return new Response(readable, {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
}

Frontend React:

import { useState, useRef } from "react";

export default function Chat() {
  const [messages, setMessages] = useState<{ role: string; content: string }[]>([]);
  const [input, setInput] = useState("");
  const [streaming, setStreaming] = useState(false);
  const abortRef = useRef<AbortController | null>(null);

  async function sendMessage() {
    if (!input.trim() || streaming) return;
    const userMsg = { role: "user", content: input };
    const newMessages = [...messages, userMsg];
    setMessages(newMessages);
    setInput("");
    setStreaming(true);

    // Placeholder per la risposta AI
    setMessages((prev) => [...prev, { role: "assistant", content: "" }]);

    abortRef.current = new AbortController();

    const res = await fetch("/api/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ messages: newMessages }),
      signal: abortRef.current.signal,
    });

    const reader = res.body!.getReader();
    const decoder = new TextDecoder();

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      const chunk = decoder.decode(value);
      setMessages((prev) => {
        const updated = [...prev];
        updated[updated.length - 1] = {
          role: "assistant",
          content: updated[updated.length - 1].content + chunk,
        };
        return updated;
      });
    }

    setStreaming(false);
  }

  function cancelStream() {
    abortRef.current?.abort();
    setStreaming(false);
  }

  return (
    <div>
      {messages.map((m, i) => (
        <div key={i} className={m.role}>{m.content}</div>
      ))}
      <input value={input} onChange={(e) => setInput(e.target.value)} />
      <button onClick={sendMessage} disabled={streaming}>Invia</button>
      {streaming && <button onClick={cancelStream}>Interrompi</button>}
    </div>
  );
}

AbortController: cancellare lo stream

L’AbortController è fondamentale per una buona UX: permette all’utente di interrompere una risposta lunga prima che sia completata. Dal lato server, quando il client chiude la connessione, il readable stream di Next.js propaga automaticamente il segnale di abort — quindi non devi fare nulla di speciale lato backend. La pulizia avviene automaticamente.

Ricorda di gestire l’errore AbortError nel catch del fetch:

try {
  const res = await fetch("/api/chat", { signal: abortRef.current.signal, ... });
  // ...
} catch (err) {
  if (err instanceof Error && err.name === "AbortError") {
    console.log("Stream cancellato dall'utente");
    return;
  }
  throw err;
}

Streaming con tool use

Quando Claude usa un tool durante lo streaming, gli eventi cambiano: invece di text_delta, arrivano eventi input_json_delta con chunk di JSON parziale. Il modo più semplice per gestire lo streaming con tool use è usare stream.finalMessage() dopo aver consumato tutti gli eventi — avrai il messaggio completo con i tool_use blocks già assemblati, senza dover ricostruire il JSON dai chunk.

const stream = client.messages.stream({
  model: "claude-opus-4-6",
  max_tokens: 1024,
  tools: [myTool],
  messages: [{ role: "user", content: prompt }],
});

// Mostra testo mentre arriva
stream.on("text", (text) => process.stdout.write(text));

// Aspetta il messaggio finale per i tool calls
const message = await stream.finalMessage();

if (message.stop_reason === "tool_use") {
  const toolUse = message.content.find((b) => b.type === "tool_use");
  // Esegui il tool e continua la conversazione
}

Risorse correlate

Approfondisci sul blog

Documentazione e strumenti

FAQ

Lo streaming aumenta il costo dei token?

No. I token vengono fatturati allo stesso modo sia con che senza streaming. Lo streaming non genera token extra — cambia solo il modo in cui la risposta viene trasmessa al client.

Posso usare lo streaming con prompt caching?

Sì, i due meccanismi sono indipendenti. Puoi aggiungere cache_control ai messaggi di sistema e usare streaming normalmente. Il caching agisce lato Anthropic prima che lo stream inizi.

Come gestisco gli errori durante lo stream?

Usa l’event handler stream.on("error", ...) oppure avvolgi il ciclo for await in un try/catch. Gli errori più comuni sono timeout di rete (gestibili con retry) e rate limit (429 — riduci la frequenza delle richieste).

Lo streaming funziona con i modelli Haiku e Sonnet?

Sì, tutti i modelli Claude (Haiku 4.5, Sonnet 4.6, Opus 4.6) supportano lo streaming con la stessa API. Haiku è ottimo per streaming a bassa latenza quando la velocità conta più della qualità; Opus per output di alta qualità dove la latenza è accettabile.

Posso fare streaming da un backend Express invece di Next.js?

Sì. Con Express usa res.setHeader("Content-Type", "text/plain") e scrivi direttamente con res.write(chunk) nel ciclo for-await, poi chiudi con res.end(). Il client legge la risposta nello stesso modo con il ReadableStream del fetch.

Condividi

Articoli Recenti

Categorie popolari