Chapter 8: SSE / NDJSON Streaming
Streaming Without WebSocket
An AI chatbot types back one token at a time. A dashboard counter ticks up as orders arrive. A notification feed shows events the moment they happen. The data flows from server to client, but you don't need the complexity of WebSocket for it.
Server-Sent Events (SSE) is the browser's built-in streaming protocol. One-directional: server pushes, client listens. It reconnects automatically. It works over HTTP/2. It's simpler than WebSocket for every case where you don't need to send data back.
NDJSON (Newline-Delimited JSON) is the same idea over a regular fetch response. One JSON object per line, streamed as the server generates them. This is how most AI APIs stream tokens — and it supports POST requests and custom headers, which SSE's EventSource does not.
tina4-js wraps both in a single module. One function call opens the stream. Six reactive signals track the state. A pipe function accumulates messages into your signals with a reducer. Auto-reconnect handles dropped connections.
1. The SSE Client
The tina4-js SSE client provides:
- Dual mode — native EventSource for SSE, fetch+ReadableStream for NDJSON
- Reactive signals —
status,connected,lastMessage,lastEvent,error,reconnectCount - Auto-reconnect — exponential backoff, configurable attempts
- Signal piping — stream messages into signals with a reducer
import { sse } from 'tina4js';2. EventSource Mode (Default)
const stream = sse.connect('/api/events');One line. The browser opens an EventSource connection. Messages arrive as the server sends them.
import { sse, effect } from 'tina4js';
const stream = sse.connect('/api/events');
effect(() => {
console.log('Status:', stream.status.value);
});
effect(() => {
if (stream.lastMessage.value) {
console.log('Received:', stream.lastMessage.value);
}
});3. Fetch Mode (NDJSON)
EventSource only supports GET requests with no custom headers. For POST requests, Bearer tokens, or NDJSON streaming, use fetch mode:
const stream = sse.connect('/api/chat', {
mode: 'fetch',
method: 'POST',
headers: { 'Authorization': 'Bearer eyJ...' },
body: { prompt: 'Explain signals in tina4-js' },
});The client reads the response body as a stream, splits on newlines, and parses each line as JSON. Every parsed object becomes a message.
4. Options
const stream = sse.connect('/api/events', {
mode: 'eventsource', // 'eventsource' (default) or 'fetch'
method: 'GET', // HTTP method (fetch mode only)
headers: {}, // Custom headers (fetch mode only)
body: undefined, // Request body (fetch mode only, auto-stringified)
reconnect: true, // Auto-reconnect on disconnect
reconnectDelay: 1000, // Initial delay (ms)
reconnectMaxDelay: 30000,// Max delay after backoff (ms)
reconnectAttempts: Infinity, // Max attempts
events: [], // Named SSE events (eventsource mode only)
json: true, // Auto-parse JSON (default: true)
});| Option | Default | Description |
|---|---|---|
mode | 'eventsource' | Transport: native EventSource or fetch+ReadableStream |
method | 'GET' | HTTP method (fetch mode only) |
headers | {} | Custom headers (fetch mode only) |
body | undefined | Request body (fetch mode, auto JSON.stringify) |
reconnect | true | Auto-reconnect on disconnect |
reconnectDelay | 1000 | Initial reconnect delay in ms |
reconnectMaxDelay | 30000 | Max delay for exponential backoff |
reconnectAttempts | Infinity | Max reconnect attempts |
events | [] | Named SSE events to listen for |
json | true | Auto-parse messages as JSON |
5. Reactive Signals
Every stream exposes six reactive signals:
const stream = sse.connect('/api/events');
stream.status // Signal<'connecting' | 'open' | 'closed' | 'reconnecting'>
stream.connected // Signal<boolean>
stream.lastMessage // Signal<unknown> — last parsed message
stream.lastEvent // Signal<string | null> — SSE event name or null
stream.error // Signal<Event | Error | null>
stream.reconnectCount // Signal<number>Use them in effects, computed values, or html templates:
effect(() => {
if (stream.connected.value) {
console.log('Stream is live');
}
});6. Event Handlers
Register listeners for stream events. Every handler returns an unsubscribe function:
const unsub = stream.on('message', (data, event?) => {
console.log('Data:', data);
console.log('Event name:', event); // SSE event name or undefined
});
stream.on('open', () => console.log('Connected'));
stream.on('close', () => console.log('Disconnected'));
stream.on('error', (err) => console.error('Error:', err));
// Stop listening
unsub();7. Named Events (EventSource Mode)
SSE supports named events. By default, EventSource only listens for unnamed message events. Pass event names in the options to listen for specific types:
const stream = sse.connect('/api/feed', {
events: ['user_joined', 'message', 'user_left'],
});
stream.on('message', (data, event) => {
switch (event) {
case 'user_joined':
console.log(`${data.name} joined`);
break;
case 'message':
console.log(`${data.author}: ${data.text}`);
break;
case 'user_left':
console.log(`${data.name} left`);
break;
}
});
// The lastEvent signal tracks the most recent event name
effect(() => console.log('Last event type:', stream.lastEvent.value));8. Pipe to Signal
The pipe pattern streams messages into a signal through a reducer. This is the same pattern as the WebSocket module:
import { sse, signal } from 'tina4js';
const messages = signal([]);
const stream = sse.connect('/api/notifications');
stream.pipe(messages, (msg, current) => [...current, msg]);
// messages.value grows as notifications arrive
effect(() => {
console.log(`${messages.value.length} notifications`);
});Pipe returns an unsubscribe function:
const unsub = stream.pipe(messages, (msg, current) => [...current, msg]);
// Stop piping
unsub();9. Auto-Reconnect
In EventSource mode, the browser handles reconnection natively. If the connection is fully closed, tina4-js schedules manual reconnection with exponential backoff.
In fetch mode, tina4-js handles all reconnection:
const stream = sse.connect('/api/stream', {
mode: 'fetch',
reconnect: true,
reconnectDelay: 1000, // Start at 1s
reconnectMaxDelay: 30000, // Cap at 30s
reconnectAttempts: 10, // Give up after 10 tries
});
effect(() => {
if (stream.status.value === 'reconnecting') {
console.log(`Reconnect attempt ${stream.reconnectCount.value}`);
}
});10. Closing
stream.close();This stops the connection and prevents reconnection. In EventSource mode it calls source.close(). In fetch mode it aborts the fetch request.
11. SSE vs WebSocket
| SSE | WebSocket | |
|---|---|---|
| Direction | Server → Client | Bidirectional |
| Protocol | HTTP | WS/WSS |
| Reconnect | Built-in (EventSource) | Manual (tina4-js handles it) |
| Headers | No (EventSource) / Yes (fetch mode) | Subprotocols only |
| POST body | No (EventSource) / Yes (fetch mode) | N/A |
| Binary data | No | Yes |
| HTTP/2 multiplexing | Yes | No |
| Use case | Notifications, feeds, AI streaming | Chat, gaming, live collaboration |
Rule of thumb: If the client only needs to receive, use SSE. If the client needs to send too, use WebSocket.
12. Real-World Example: AI Chat Streaming
import { sse, signal, html } from 'tina4js';
const messages = signal([]);
const input = signal('');
const streaming = signal(false);
async function sendMessage() {
const prompt = input.value.trim();
if (!prompt) return;
// Add user message
messages.value = [...messages.value, { role: 'user', text: prompt }];
input.value = '';
// Add empty assistant message
messages.value = [...messages.value, { role: 'assistant', text: '' }];
streaming.value = true;
// Stream tokens
const stream = sse.connect('/api/chat', {
mode: 'fetch',
method: 'POST',
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` },
body: { prompt },
});
stream.on('message', (data) => {
const token = data.token || data;
const msgs = [...messages.value];
const last = msgs[msgs.length - 1];
msgs[msgs.length - 1] = { ...last, text: last.text + token };
messages.value = msgs;
});
stream.on('close', () => {
streaming.value = false;
});
}
const view = html`
<div class="chat">
${() => messages.value.map(m => html`
<div class="message ${m.role}">
<strong>${m.role}:</strong> ${m.text}
</div>
`)}
<div class="input-bar">
<input
type="text"
.value=${input}
@input=${(e) => { input.value = e.target.value; }}
@keydown=${(e) => { if (e.key === 'Enter') sendMessage(); }}
?disabled=${streaming}
/>
<button @click=${sendMessage} ?disabled=${streaming}>Send</button>
</div>
</div>
`;13. Real-World Example: Live Notification Feed
import { sse, signal, html } from 'tina4js';
const notifications = signal([]);
const stream = sse.connect('/api/notifications', {
events: ['info', 'warning', 'error'],
});
stream.pipe(notifications, (msg, current) => {
return [{ ...msg, event: stream.lastEvent.value, time: new Date() }, ...current].slice(0, 50);
});
const view = html`
<div class="feed">
<h2>Notifications ${() => stream.connected.value ? '(live)' : '(disconnected)'}</h2>
${() => notifications.value.map(n => html`
<div class="notification ${n.event}">
<span class="badge">${n.event}</span>
${n.message}
<small>${n.time.toLocaleTimeString()}</small>
</div>
`)}
</div>
`;Bundle Size
| Module | Raw | Gzipped |
|---|---|---|
| SSE | 3.42 KB | 1.30 KB |
Import only what you need:
import { sse } from 'tina4js/sse'; // 1.30 KB gzipSummary
| Task | Code |
|---|---|
| Connect (EventSource) | sse.connect('/events') |
| Connect (NDJSON/POST) | sse.connect('/api', { mode: 'fetch', method: 'POST', body: {...} }) |
| Read status | stream.status.value |
| Listen for messages | stream.on('message', (data, event?) => { ... }) |
| Named events | sse.connect(url, { events: ['update', 'delete'] }) |
| Pipe to signal | stream.pipe(signal, (msg, current) => [...current, msg]) |
| Close | stream.close() |