Skip to main content

SSE Streaming Guide

Learn how to implement Server-Sent Events (SSE) streaming for real-time AI completions.

What is Server-Sent Events?

Server-Sent Events (SSE) is an HTTP standard for streaming data from server to client over a persistent connection.

Key characteristics:

  • Unidirectional: Server → Client only (no client-to-server streaming)
  • HTTP-based: Uses standard HTTP/HTTPS (no special protocol)
  • Auto-reconnection: Built into browsers (for EventSource API)
  • Simpler than WebSockets: No protocol upgrade needed

Why SSE for AI completions?

  • Real-time updates: Display responses as they're generated
  • Better UX: Users see progress instead of waiting for completion
  • Lower latency: First tokens arrive quickly
  • Standard HTTP: Works through proxies, load balancers, CDNs

Connection Pattern

Client opens connection

Server sends events
├─ data: event1
├─ data: event2
└─ data: event3

Server sends [DONE]

Connection closes

SSE Format

Each event follows this format:

data: {JSON payload}\n\n

Key points:

  • Starts with data: prefix
  • Contains JSON payload
  • Ends with double newline (\n\n)

Example stream:

data: {"type":"content","text":"Hello","usage":{"inputTokens":10,"outputTokens":5}}

data: {"type":"content","text":" world!","usage":{"inputTokens":10,"outputTokens":10}}

data: [DONE]


Implementation Examples

JavaScript (Browser - Fetch API)

The most common approach for web applications:

async function streamCompletion(messages) {
const response = await fetch('https://app.bike4mind.com/api/ai/v1/completions', {
method: 'POST',
headers: {
'X-API-Key': process.env.B4M_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'claude-3-5-sonnet',
messages: messages
})
});

// Check for errors before streaming
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';

while (true) {
const { done, value } = await reader.read();
if (done) break;

// Decode chunk and add to buffer
buffer += decoder.decode(value, { stream: true });

// Split by newlines
const lines = buffer.split('\n');

// Keep incomplete line in buffer
buffer = lines.pop() || '';

// Process complete lines
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim();

// Check for completion signal
if (data === '[DONE]') {
console.log('Stream complete');
return;
}

try {
const event = JSON.parse(data);
handleEvent(event);
} catch (err) {
console.error('JSON parse error:', err, 'Raw data:', data);
}
}
}
}
}

function handleEvent(event) {
switch (event.type) {
case 'content':
console.log('Content:', event.text);
console.log('Usage:', event.usage);
break;
case 'tool_use':
console.log('Tool call:', event.tools);
console.log('Response:', event.text);
break;
case 'error':
console.error('Error:', event.message);
break;
default:
console.warn('Unknown event type:', event.type);
}
}

// Usage
streamCompletion([
{ role: 'user', content: 'What is 2+2?' }
]);

Key points:

  1. Use response.body.getReader() for streaming
  2. Buffer incomplete lines (SSE events may span multiple chunks)
  3. Parse only complete data: lines
  4. Handle [DONE] signal to close stream
  5. Always wrap JSON.parse in try/catch

Node.js (with node-fetch)

For server-side JavaScript:

import fetch from 'node-fetch';

async function streamCompletion(apiKey, messages) {
const response = await fetch('https://app.bike4mind.com/api/ai/v1/completions', {
method: 'POST',
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'claude-3-5-sonnet',
messages: messages
})
});

if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}

let buffer = '';

response.body.on('data', (chunk) => {
buffer += chunk.toString();

// Split by double newline (SSE message separator)
const messages = buffer.split('\n\n');
buffer = messages.pop() || ''; // Keep incomplete message

for (const message of messages) {
if (message.startsWith('data: ')) {
const data = message.slice(6).trim();

if (data === '[DONE]') {
console.log('Stream complete');
return;
}

try {
const event = JSON.parse(data);
handleEvent(event);
} catch (err) {
console.error('Parse error:', err);
}
}
}
});

response.body.on('end', () => {
console.log('Connection closed');
});

response.body.on('error', (err) => {
console.error('Stream error:', err);
});
}

// Usage
const apiKey = process.env.B4M_API_KEY;
const messages = [{ role: 'user', content: 'What is 2+2?' }];
streamCompletion(apiKey, messages);

Python (with requests + sseclient-py)

import json
import os
import requests
from sseclient import SSEClient

def stream_completion(api_key: str, messages: list):
"""
Stream a completion from the B4M API.

Args:
api_key: B4M API key
messages: List of message dicts with 'role' and 'content'
"""
url = "https://app.bike4mind.com/api/ai/v1/completions"
headers = {
"X-API-Key": api_key,
"Content-Type": "application/json",
}
payload = {
"model": "claude-3-5-sonnet",
"messages": messages
}

response = requests.post(url, headers=headers, json=payload, stream=True)
response.raise_for_status() # Raise error for non-200 status

client = SSEClient(response)

for event in client.events():
if event.data == "[DONE]":
print("\n✓ Stream complete")
break

try:
data = json.loads(event.data)
handle_event(data)
except json.JSONDecodeError as e:
print(f"\nJSON parse error: {e}")

def handle_event(event):
event_type = event.get("type")

if event_type == "content":
print(f"Content: {event['text']}")
print(f"Usage: {event.get('usage', {})}")
elif event_type == "tool_use":
print(f"Tool call: {event['tools']}")
print(f"Response: {event['text']}")
elif event_type == "error":
print(f"Error: {event['message']}")
else:
print(f"Unknown event type: {event_type}")

# Usage
if __name__ == "__main__":
api_key = os.environ.get("B4M_API_KEY")
messages = [{"role": "user", "content": "What is 2+2?"}]
stream_completion(api_key, messages)

Install sseclient-py:

pip install sseclient-py requests

curl (for testing)

The simplest way to test SSE streaming:

curl -X POST https://app.bike4mind.com/api/ai/v1/completions \
-H "X-API-Key: $B4M_API_KEY" \
-H "Content-Type: application/json" \
--no-buffer \
-d '{
"model": "claude-3-5-sonnet",
"messages": [
{"role": "user", "content": "What is 2+2?"}
]
}'

Critical: Always use --no-buffer to see streaming output in real-time. Without it, curl will buffer the entire response.


Error Handling

Errors can occur at two points in the stream lifecycle.

1. Before Streaming Starts

These errors occur during connection setup (authentication, validation, rate limiting).

Indicators:

  • HTTP error status (401, 403, 429, 500, etc.)
  • No SSE stream starts

Example handling:

const response = await fetch(...);

// Check HTTP status first
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}

// If OK, start parsing stream
const reader = response.body.getReader();
// ...

2. During Streaming

These errors occur while the stream is active (model errors, timeouts, etc.).

Indicators:

  • SSE error event: {"type":"error","message":"..."}
  • Stream is active but returns error

Example handling:

function handleEvent(event) {
if (event.type === 'error') {
throw new Error(`Stream error: ${event.message}`);
}

// Handle other event types
// ...
}

Complete error handling example:

async function streamWithErrorHandling(messages) {
try {
const response = await fetch('https://app.bike4mind.com/api/ai/v1/completions', {
method: 'POST',
headers: {
'X-API-Key': process.env.B4M_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'claude-3-5-sonnet',
messages: messages
})
});

// Error before streaming
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';

try {
while (true) {
const { done, value } = await reader.read();
if (done) break;

buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';

for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim();

if (data === '[DONE]') {
return; // Success
}

const event = JSON.parse(data);

// Error during streaming
if (event.type === 'error') {
throw new Error(`Stream error: ${event.message}`);
}

handleEvent(event);
}
}
}
} finally {
reader.releaseLock(); // Always release reader
}

} catch (error) {
console.error('Stream failed:', error);
throw error;
}
}

Common Pitfalls

1. Buffering Issues

Problem: HTTP clients may buffer responses, preventing real-time streaming.

Symptoms:

  • Seeing entire response at once instead of chunks
  • Long wait before any output appears

Solutions:

curl:

curl --no-buffer ... # Always use this flag

Python requests:

response = requests.post(..., stream=True) # Set stream=True

Node.js: Streams work correctly by default

Proxies (nginx, CDN): The API sets X-Accel-Buffering: no to disable buffering


2. Partial JSON Parsing

Problem: SSE events may arrive split across multiple chunks, causing JSON.parse errors.

Symptoms:

  • "Unexpected end of JSON input" errors
  • "Unexpected token" at start of JSON

Solution: Always buffer incomplete lines

let buffer = '';

for (const chunk of chunks) {
buffer += chunk;

const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line

for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim();

// Now safe to parse
const event = JSON.parse(data);
}
}
}

3. Not Handling [DONE]

Problem: Continuing to read stream after [DONE] signal.

Symptoms:

  • Stream never closes
  • Memory leaks from unclosed connections

Solution: Always check for [DONE] and return/break

if (data === '[DONE]') {
console.log('Stream complete');
return; // Exit stream handler
}

4. Memory Leaks

Problem: Not closing readers/connections properly.

Symptoms:

  • Increasing memory usage over time
  • "Too many open connections" errors

Solution: Always clean up in finally blocks

const reader = response.body.getReader();

try {
// Stream processing
} finally {
reader.releaseLock(); // Always release
}

5. Ignoring Unknown Event Types

Problem: Future API versions may add new event types.

Symptoms:

  • Code breaks when new event types are added

Solution: Handle unknown types gracefully

function handleEvent(event) {
switch (event.type) {
case 'content':
// Handle content
break;
case 'tool_use':
// Handle tool use
break;
case 'error':
// Handle error
break;
default:
// Log but don't crash
console.warn('Unknown event type:', event.type);
}
}

Best Practices

1. Buffer Incomplete Lines

Always keep incomplete SSE lines in a buffer:

let buffer = '';

// For each chunk
buffer += decodedChunk;
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep last (potentially incomplete) line

2. Handle All Event Types

Process all event types, including unknown ones:

switch (event.type) {
case 'content': // Content
case 'tool_use': // Tool calls
case 'error': // Errors
default: // Unknown (log, don't crash)
}

3. Implement Timeouts

Don't wait forever for [DONE]:

const TIMEOUT_MS = 60000; // 60 seconds

const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Stream timeout')), TIMEOUT_MS);
});

const streamPromise = streamCompletion(messages);

await Promise.race([streamPromise, timeoutPromise]);

4. Close Streams Properly

Always clean up resources:

try {
await streamCompletion(messages);
} finally {
reader.releaseLock();
// Close any other resources
}

5. Log Parsing Errors

Debug SSE parsing issues:

try {
const event = JSON.parse(data);
} catch (err) {
console.error('Parse error:', err);
console.error('Raw data:', data); // Log what failed to parse
}

6. Check HTTP Status First

Validate response before parsing stream:

if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}

// Now safe to parse stream

7. Use Exponential Backoff

Retry failed streams with backoff:

async function streamWithRetry(messages, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await streamCompletion(messages);
} catch (err) {
if (attempt < maxRetries - 1) {
const delay = Math.pow(2, attempt) * 1000;
await sleep(delay);
} else {
throw err;
}
}
}
}

Testing Streams

Testing with curl

# Basic test
curl -X POST https://app.bike4mind.com/api/ai/v1/completions \
-H "X-API-Key: $B4M_API_KEY" \
-H "Content-Type: application/json" \
--no-buffer \
-d '{
"model": "claude-3-5-sonnet",
"messages": [{"role": "user", "content": "Count to 5"}]
}'

# Verbose (shows headers)
curl -v --no-buffer ...

# Save to file
curl --no-buffer ... > output.txt

Testing Programmatically

async function testStream() {
console.log('Testing SSE stream...');

const messages = [{ role: 'user', content: 'Say hello' }];

let eventCount = 0;
let receivedDone = false;

await streamCompletion(messages, (event) => {
eventCount++;
console.log(`Event ${eventCount}:`, event.type);

if (event.type === 'content') {
console.log(' Text:', event.text);
}
});

receivedDone = true;

console.log(`✓ Stream test passed`);
console.log(` Events received: ${eventCount}`);
console.log(` [DONE] signal: ${receivedDone ? 'yes' : 'no'}`);
}

testStream();

Troubleshooting

Stream Never Starts

Check:

  1. HTTP status code (should be 200)
  2. Authentication header (valid API key)
  3. Request body (valid JSON, correct schema)

Stream Buffers Instead of Streaming

Check:

  1. Using --no-buffer in curl
  2. Using stream=True in Python requests
  3. Not using incorrect HTTP client
  4. No buffering proxies in the middle

JSON Parse Errors

Check:

  1. Buffering incomplete lines properly
  2. Splitting by \n\n for SSE message boundaries
  3. Wrapping JSON.parse in try/catch
  4. Logging raw data before parsing

Stream Never Ends

Check:

  1. Handling [DONE] signal properly
  2. Closing connection after [DONE]
  3. Not continuing to read after completion
  4. Implementing timeout for safety

Next Steps