Skip to content

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.1
Content-Type: application/json

Once 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 TypeDescriptionSSE Compatible
application/octet-streamBinary data (default)No
application/jsonJSON with special semanticsYes
application/ndjsonNewline-delimited JSONYes
text/plainPlain textYes
text/event-streamSSE formatYes
application/x-protobufProtocol BuffersNo
Custom typesAny valid MIME typeDepends

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.1
Content-Type: text/plain
Hello World
HTTP/1.1 409 Conflict
Content-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/json
application/json; charset=UTF-8 → application/json
application/json → application/json

These 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.1
Content-Type: application/json
{"event": "click"}
POST /stream HTTP/1.1
Content-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 BodyStored 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 request
const 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 messages

Empty Array Rejection

Empty arrays are rejected as they represent no-op operations:

POST /stream HTTP/1.1
Content-Type: application/json
[]
HTTP/1.1 400 Bad Request
Content-Type: application/json
{"error": "Empty JSON array"}

JSON Validation

Servers MUST validate JSON before storing:

POST /stream HTTP/1.1
Content-Type: application/json
{invalid json
HTTP/1.1 400 Bad Request
Content-Type: application/json
{"error": "Invalid JSON: Unexpected token"}

Response Format

Reads from JSON streams return arrays:

GET /stream?offset=-1 HTTP/1.1
HTTP/1.1 200 OK
Content-Type: application/json
[{"event": "click"}, {"event": "scroll"}, {"event": "keypress"}]

Empty ranges return empty arrays:

HTTP/1.1 200 OK
Content-Type: application/json
Stream-Up-To-Date: true
[]

Binary Mode

Streams with binary content types operate at the byte level:

PUT /stream HTTP/1.1
Content-Type: application/octet-stream

No Message Boundaries

Binary streams don’t preserve message boundaries:

# First append
POST /stream HTTP/1.1
Content-Type: application/octet-stream
Hello
# Second append
POST /stream HTTP/1.1
Content-Type: application/octet-stream
World

Reading returns concatenated bytes:

HelloWorld

Protocol Buffers Example

PUT /events HTTP/1.1
Content-Type: application/x-protobuf
// Client must handle message framing
const 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.1
Content-Type: application/ndjson
POST /logs HTTP/1.1
Content-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 TypeSSEReason
text/*YesText content
application/jsonYesJSON is text
application/ndjsonYesJSON is text
application/octet-streamNoBinary data
application/x-protobufNoBinary data
image/*NoBinary data

Attempting SSE with incompatible content type:

GET /binary-stream?offset=-1&live=sse HTTP/1.1
HTTP/1.1 400 Bad Request
Content-Type: application/json
{"error": "SSE mode requires text/* or application/json content type"}

Best Practices

Choose the Right Content Type

Use CaseRecommended Type
Structured eventsapplication/json
Log aggregationapplication/ndjson
Chat messagesapplication/json
Binary protocolsapplication/x-protobuf
File uploadsapplication/octet-stream
Debug outputtext/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