Content Types
Every stream has a content type that determines how data is stored and returned. This page documents content type handling and the special JSON mode.
Setting Content Type
Content type is set at stream creation via the Content-Type header:
PUT /stream HTTP/1.1Content-Type: application/jsonOnce set, the content type cannot be changed. All appends MUST use the same content type.
Supported Content Types
The protocol supports any MIME content type:
| Content Type | Description | SSE Compatible |
|---|---|---|
application/octet-stream | Binary data (default) | No |
application/json | JSON with special semantics | Yes |
application/ndjson | Newline-delimited JSON | Yes |
text/plain | Plain text | Yes |
text/event-stream | SSE format | Yes |
application/x-protobuf | Protocol Buffers | No |
| Custom types | Any valid MIME type | Depends |
Default Content Type
If no Content-Type header is provided during creation, the server defaults to application/octet-stream.
Content Type Matching
Appends MUST match the stream’s content type:
# Stream created with application/json
POST /stream HTTP/1.1Content-Type: text/plain
Hello WorldHTTP/1.1 409 ConflictContent-Type: application/json
{"error": "Content type mismatch: expected application/json, got text/plain"}Charset Handling
Content types with charset parameters are normalized:
application/json; charset=utf-8 → application/jsonapplication/json; charset=UTF-8 → application/jsonapplication/json → application/jsonThese all match each other.
JSON Mode
Streams with Content-Type: application/json have special semantics that preserve message boundaries and enable batch operations.
Message Boundaries
Each append creates a distinct message:
POST /stream HTTP/1.1Content-Type: application/json
{"event": "click"}POST /stream HTTP/1.1Content-Type: application/json
{"event": "scroll"}Reading returns messages as an array:
[{ "event": "click" }, { "event": "scroll" }]Array Flattening
When appending a JSON array, the protocol flattens one level:
| Append Body | Stored Messages |
|---|---|
{"a": 1} | {"a": 1} (1 message) |
[{"a": 1}, {"b": 2}] | {"a": 1}, {"b": 2} (2 messages) |
[[1,2], [3,4]] | [1,2], [3,4] (2 messages) |
[[[1,2,3]]] | [[1,2,3]] (1 message) |
This enables efficient batch operations:
// Send 100 events in one requestconst events = []for (let i = 0; i < 100; i++) { events.push({ event: "tick", n: i })}
await fetch("/stream", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(events),})// Stores 100 separate messagesEmpty Array Rejection
Empty arrays are rejected as they represent no-op operations:
POST /stream HTTP/1.1Content-Type: application/json
[]HTTP/1.1 400 Bad RequestContent-Type: application/json
{"error": "Empty JSON array"}JSON Validation
Servers MUST validate JSON before storing:
POST /stream HTTP/1.1Content-Type: application/json
{invalid jsonHTTP/1.1 400 Bad RequestContent-Type: application/json
{"error": "Invalid JSON: Unexpected token"}Response Format
Reads from JSON streams return arrays:
GET /stream?offset=-1 HTTP/1.1HTTP/1.1 200 OKContent-Type: application/json
[{"event": "click"}, {"event": "scroll"}, {"event": "keypress"}]Empty ranges return empty arrays:
HTTP/1.1 200 OKContent-Type: application/jsonStream-Up-To-Date: true
[]Binary Mode
Streams with binary content types operate at the byte level:
PUT /stream HTTP/1.1Content-Type: application/octet-streamNo Message Boundaries
Binary streams don’t preserve message boundaries:
# First appendPOST /stream HTTP/1.1Content-Type: application/octet-stream
Hello
# Second appendPOST /stream HTTP/1.1Content-Type: application/octet-stream
WorldReading returns concatenated bytes:
HelloWorldProtocol Buffers Example
PUT /events HTTP/1.1Content-Type: application/x-protobuf// Client must handle message framingconst message = MyEvent.encode({ type: "click", x: 100 }).finish()const length = Buffer.alloc(4)length.writeUInt32BE(message.length, 0)
await fetch("/events", { method: "POST", headers: { "Content-Type": "application/x-protobuf" }, body: Buffer.concat([length, message]),})NDJSON Mode
Newline-delimited JSON uses line boundaries:
PUT /logs HTTP/1.1Content-Type: application/ndjsonPOST /logs HTTP/1.1Content-Type: application/ndjson
{"level": "info", "msg": "Server started"}{"level": "warn", "msg": "High memory usage"}{"level": "error", "msg": "Connection failed"}Each line is a separate JSON object. No array wrapping in responses.
SSE Compatibility
Only text-based content types work with SSE mode:
| Content Type | SSE | Reason |
|---|---|---|
text/* | Yes | Text content |
application/json | Yes | JSON is text |
application/ndjson | Yes | JSON is text |
application/octet-stream | No | Binary data |
application/x-protobuf | No | Binary data |
image/* | No | Binary data |
Attempting SSE with incompatible content type:
GET /binary-stream?offset=-1&live=sse HTTP/1.1HTTP/1.1 400 Bad RequestContent-Type: application/json
{"error": "SSE mode requires text/* or application/json content type"}Best Practices
Choose the Right Content Type
| Use Case | Recommended Type |
|---|---|
| Structured events | application/json |
| Log aggregation | application/ndjson |
| Chat messages | application/json |
| Binary protocols | application/x-protobuf |
| File uploads | application/octet-stream |
| Debug output | text/plain |
JSON Mode for Most Applications
JSON mode provides the best developer experience:
- Message boundaries preserved automatically
- Batch operations with array flattening
- SSE compatible
- Human-readable in logs and debugging
Binary Mode for Performance
Use binary content types when:
- Message overhead matters
- Using Protocol Buffers or similar
- Streaming raw media data
- Implementing custom framing
Next Steps
- Reading Operations - Content type in responses
- Writing Operations - Content type requirements
- SSE Mode - SSE format details