WebSocket, WebTransport & NDJSON streams
tanstack-fetch/live is the client with every live transport: api.sse(), api.ws(), api.transport() and api.stream(). They all share the client's baseUrl, headers, getToken / auth, plugins and interceptors. This is a separate entry, so apps that only need SSE keep importing the smaller tanstack-fetch/sse.
import { createFetch } from 'tanstack-fetch/live'
const api = createFetch({
baseUrl: 'https://api.example.com', // https → wss for api.ws()
getToken: () => localStorage.getItem('access_token'),
})| Direction | Delivery | Header auth in browser | Use for | |
|---|---|---|---|---|
api.sse | server → client | reliable | ✅ | feeds, notifications |
api.ws | both | reliable, ordered | ❌ (authIn) | chat, collaboration |
api.transport | both | datagrams (may drop / reorder) | ❌ (authIn) | games, cursors, telemetry |
api.stream | server → client | reliable | ✅ | LLM tokens, exports, logs |
WebSocket: api.ws
const chat = api.ws<ChatMessage, { id: number }, OutgoingMessage>('/rooms/:id', {
params: { id: 7 },
onOpen: () => console.log('open'),
onMessage: (data) => console.log(data), // JSON frames are parsed
onReconnect: (attempt) => console.log('reconnecting', attempt),
onClose: () => console.log('closed'), // fires exactly once
})
chat.send({ text: 'hi' }) // queued until open; ignored after close
chat.close()Typed path params
Every live call (sse, ws, transport, stream) and every useSse / useWs hook types params exactly like api.get:
api.ws('/rooms/:id', { params: { id: 7 } }) // inferred from the URL — `id` required
api.ws<ChatMessage, { id: number }>('/rooms/:id', { params: { id: 7 } }) // your type; path must contain :id
api.ws<ChatMessage, NoParams, OutgoingMessage>('/feed') // skip params, type outgoing framesGenerics follow HTTP: <Message, Params, Send> for ws / transport / useWs, <Message, Params> for sse / stream / useSse. Passing only <Message> loses the path literal (a TypeScript limit), so add the params type to keep params checked.
Absolute URLs work too: api.ws('wss://realtime.example.com/socket').
Live demo
Live demo · WebSocket
useWs — auth + reconnect
Chat against a mock socket. Send before it opens (queued), then drop the connection: it reconnects and getToken() runs again, so the URL gets a fresh token.
status: disconnected
import { createFetch } from 'tanstack-fetch/live'
import { useWs } from 'tanstack-fetch/react'
const api = createFetch({ baseUrl, getToken })
const ChatRoom = ({ id }: { id: number }) => {
const { data, status, send, close } = useWs<ChatMessage, { id: number }>('/rooms/:id', {
client: api,
params: { id },
authIn: 'query', // browsers can't send WS headers
reconnect: { retries: 3, delayMs: 600 },
})
return (
<>
<p>{status} · {data?.text}</p>
<button onClick={() => send({ text: 'hi' })}>Send</button>
<button onClick={close}>Close</button>
</>
)
}Auth
The token comes from getToken / auth / interceptors (including refresh-token), and it is fetched again on every reconnect. Where it goes is set by authIn:
authIn | Where the token goes | Default |
|---|---|---|
header | Authorization: Bearer … (plus all client headers) | Node 22+, Bun, Deno |
query | ?token=… (rename with authQueryKey) | — |
protocol | appended to Sec-WebSocket-Protocol | — |
Browsers and Web Workers can't send WebSocket headers. There, if a token exists and authIn isn't set, onError fires and nothing is sent, so your token never lands in a URL unless you choose that:
api.ws('/live', { authIn: 'query' })Query tokens
URLs can end up in server and proxy logs. Prefer short-lived tokens with authIn: 'query'. With authIn: 'protocol', the server has to echo one of the offered subprotocols, or the browser drops the connection.
On Node < 22 (no global WebSocket), plug in the ws package:
import WS from 'ws'
createFetch({
baseUrl: 'https://api.example.com',
createWebSocket: (url, { protocols, headers }) => new WS(url, protocols, { headers }) as never,
})Options
| Option | Default | |
|---|---|---|
reconnect | { retries: 5, delayMs: 1000 } | Exponential backoff (max 30s). false disables it. Failed token refreshes count as attempts |
protocols | — | Subprotocols |
parseAs | JSON when possible | 'text' keeps raw strings. Binary frames are passed as-is |
signal | — | Aborting closes the socket |
transport | 'websocket' | 'webtransport', see below |
WebTransport: api.transport
The same API over an HTTP/3 WebTransport session. Each message is a datagram: low latency and never blocked by a lost packet, but it can be dropped or arrive out of order. Use it when the newest value matters more than every value.
const game = api.transport<GameState, { id: string }, PlayerInput>('/game/:id', {
params: { id },
authIn: 'query', // WebTransport can't send headers. Set this whenever there's a token
onMessage: (state) => draw(state),
})
game.send({ dx: 1, dy: 0 })Live demo · WebTransport
useWs({ transport: 'webtransport' })
Game state streams in as unreliable datagrams (newest value wins). Same API as api.ws: send input back with send().
status: disconnected
import { useWs } from 'tanstack-fetch/react'
const Game = () => {
const { data: state, status, send } = useWs<GameState, { id: number }, Input>('/game/:id', {
client: api,
params: { id: 1 },
authIn: 'query',
transport: 'webtransport', // HTTP/3 datagrams
})
return (
<Board dot={state} live={status === 'connected'}
onBoost={() => send({ boost: 1.5 })} />
)
}- It's the same as
api.ws(path, { transport: 'webtransport' }), so auth, reconnect, the send queue and hooks all behave identically. - The URL stays
https://. - For local dev with a self-signed certificate, pass
webTransport: { serverCertificateHashes: [...] }. - For reliable streams on the same session, use
getSocket(). Itstransportproperty is the rawWebTransport. - Outside browsers, pass a polyfill through
createWebTransport.
Browser support
WebTransport ships in Chromium and Firefox. See caniuse for Safari, and fall back to api.ws(), which returns the same { send, close } object.
Framework hooks: useWs
useWs is exported from tanstack-fetch/react, /vue, /solid, /svelte and /angular, and works like useSse:
const { data, status, error, send, close } = useWs<ChatMessage, { id: string }>('/rooms/:id', {
client: api,
params: { id },
authIn: 'query',
// transport: 'webtransport',
})status: connecting → connected → disconnected | error, and back to connecting while reconnecting.
NDJSON streams: api.stream
For streaming HTTP responses that send one JSON object per line (LLM / AI APIs, exports, logs). Unlike EventSource and WebSocket, it supports any method, a request body, and headers everywhere.
for await (const chunk of api.stream<Token>('/chat', { body: { prompt } })) {
output += chunk.text
}Live demo · NDJSON
api.stream + streamedQuery
A POST with a body and an Authorization header, read line by line. EventSource cannot do either.
status: disconnected
import {
experimental_streamedQuery as streamedQuery,
useQuery,
} from '@tanstack/react-query'
const Answer = ({ prompt }: { prompt: string }) => {
const { data: tokens = [], fetchStatus, refetch } = useQuery({
queryKey: ['chat', prompt],
queryFn: streamedQuery({
streamFn: ({ signal }) =>
api.stream<Token>('/chat', { body: { prompt }, signal }),
}),
enabled: false,
})
return (
<>
<button onClick={() => refetch()}>Ask</button>
<p>{tokens.map((t) => t.text).join('')}</p>
{fetchStatus === 'fetching' && <span>streaming…</span>}
</>
)
}- The method defaults to
POSTwhenbodyis set, otherwiseGET. - Interceptors run the same way as for
api.get: auth, status handlers (onUnauthorized…),refresh-tokenretry on 401, andretrydecisions. timeoutMsonly covers the wait for response headers, never the open-ended stream.- Lines that aren't JSON are yielded as strings. Non-2xx responses throw a
FetchError.breakorsignalcancels the request.
With TanStack Query v5, pass it straight to streamedQuery:
import { experimental_streamedQuery as streamedQuery } from '@tanstack/react-query'
queryOptions({
queryKey: ['chat', prompt],
queryFn: streamedQuery({
streamFn: ({ signal }) => api.stream<Token>('/chat', { body: { prompt }, signal }),
}),
})