server-sent-event.md

August 24, 2026 · View on GitHub

ServerSentEvent[A] is an immutable envelope for one Server-Sent Event: a payload of type A plus the three optional SSE metadata fields. SseDataEncoder[A] turns the payload into the data: lines of the wire format. The type is covariant in A, and the metadata fields are Maybe rather than Option to stay allocation-free when absent:

final class ServerSentEvent[+A] private (
  val data: A,
  val eventType: Maybe[String],
  val eventId: Maybe[String],
  val retryMillis: Maybe[Long]
)

trait SseDataEncoder[-A] {
  def lines(value: A): Chunk[String]
}

Motivation

The SSE wire format is small enough to hand-write and fiddly enough to get wrong. Fields are name: value lines, the event ends with a blank line, and a multi-line payload has to become several data: lines rather than one line containing newlines. Emit a payload with an embedded \n as a single data: line and the stream desynchronizes — the client reads the remainder as a new field, or as the end of the event.

ServerSentEvent owns that formatting. The envelope holds the metadata, an SseDataEncoder decides how the payload becomes lines, and ServerSentEvent#render assembles them in the order the specification requires. Splitting a multi-line string is the encoder's job, so a payload containing newlines is correct by construction rather than by remembering.

Separating the encoder from the envelope is what lets the payload be typed. ServerSentEvent[String] and ServerSentEvent[Chunk[String]] work out of the box, and any other type works as soon as it has an encoder — the envelope never needs to change.

Quick Showcase

An event is a payload plus optional metadata, and rendering produces the wire format:

import zio.http.ServerSentEvent

val event = ServerSentEvent("hello", "greeting").id("42").retry(3000)

Rendering emits the metadata fields, then the data lines, then the blank line that terminates the event:

print(event.render)

Construction

Three entry points cover the cases: payload only, payload with an event name, and payload with any combination of metadata.

ServerSentEvent.apply — payload, optionally named

The one-argument form creates an event with no metadata at all, and the two-argument form sets the event: field:

object ServerSentEvent {
  def apply[A](data: A): ServerSentEvent[A]
  def apply[A](data: A, event: String): ServerSentEvent[A]
}

A bare event renders as a single data: line followed by the blank line:

import zio.http.ServerSentEvent

val bare  = ServerSentEvent("tick")
val named = ServerSentEvent("tick", "heartbeat")

Naming the event adds one line above the data:

print(bare.render)
print(named.render)

ServerSentEvent.fromOptions — all metadata at once

ServerSentEvent.fromOptions takes the three metadata fields as Option, each defaulting to None, which suits code that already has them in optional form:

object ServerSentEvent {
  def fromOptions[A](
    data: A,
    event: Option[String] = None,
    id: Option[String] = None,
    retry: Option[Long] = None
  ): ServerSentEvent[A]
}

Supplying a subset by name leaves the rest absent:

import zio.http.ServerSentEvent

val event = ServerSentEvent.fromOptions("payload", id = Some("7"), retry = Some(1000))

Only the fields you set appear on the wire:

print(event.render)

This is the only constructor that takes Option. The accessors return Maybe, so a round trip through ServerSentEvent.fromOptions converts between the two.

Setting Metadata

Four methods produce a modified copy. Each returns a new envelope; nothing mutates.

MethodEffect
ServerSentEvent#eventSets the event: field.
ServerSentEvent#clearEventRemoves the event: field.
ServerSentEvent#idSets the id: field.
ServerSentEvent#retrySets the retry: field, in milliseconds.
ServerSentEvent#clearRetryRemoves the retry: field.

They chain, since each returns a ServerSentEvent[A]:

import zio.http.ServerSentEvent

val event = ServerSentEvent("payload").event("update").id("100").retry(5000)

Clearing a field drops its line without disturbing the others:

print(event.clearRetry.render)
print(event.clearEvent.clearRetry.render)

:::note[There is no clearId] ServerSentEvent#clearEvent and ServerSentEvent#clearRetry exist, but the id: field has no clearing method. To produce an event without an id, build a fresh envelope or use ServerSentEvent.fromOptions with id = None. :::

The accessors expose what is set, as Maybe:

event.data
event.eventType
event.eventId
event.retryMillis

Validation

Two invariants are enforced at construction, and both throw rather than returning an error, because an invalid event cannot be rendered safely.

The event: and id: fields must not contain a carriage return or line feed. The reason is the same as for header values: a newline inside a field would terminate that field early and let the remainder be read as a new field or a new event, desynchronizing the stream.

The retry: value must be non-negative, since it is a reconnection delay in milliseconds.

Both checks are visible from a bare import:

import zio.http.ServerSentEvent

Both rejections are IllegalArgumentException with a message naming the field:

scala.util.Try(ServerSentEvent("data", "bad\nevent")).failed.map(_.getMessage)
scala.util.Try(ServerSentEvent("data").retry(-1)).failed.map(_.getMessage)

Validation applies to the metadata only. The payload is never validated, because embedded newlines in the payload are legitimate — the encoder splits them into separate data: lines.

Rendering

ServerSentEvent#render needs an SseDataEncoder for the payload type and produces the complete wire representation, terminating blank line included:

final class ServerSentEvent[+A] {
  def render(implicit encoder: SseDataEncoder[A]): String
}

Fields are emitted in a fixed order — event:, then id:, then retry:, then the data: lines — with absent fields omitted entirely. A payload that produces no lines still emits one empty data: line, so an event is never rendered without a data field:

import zio.http.ServerSentEvent
import zio.blocks.chunk.Chunk

val empty = ServerSentEvent(Chunk.empty[String])

The result is a single valueless data line and the terminator:

empty.render

SseDataEncoder

SseDataEncoder[A] maps a payload to its data: lines. It is contravariant in A, so an encoder for a supertype serves every subtype:

trait SseDataEncoder[-A] {
  def lines(value: A): Chunk[String]
}

object SseDataEncoder {
  def apply[A](implicit encoder: SseDataEncoder[A]): SseDataEncoder[A]
  implicit val string: SseDataEncoder[String]
  implicit val stringChunk: SseDataEncoder[Chunk[String]]
}

Built-in Instances

SseDataEncoder.string splits a String on line breaks, so a multi-line payload becomes one data: line per line. \n, \r, and \r\n all count as a single break:

import zio.http.ServerSentEvent

val multiline = ServerSentEvent("first line\nsecond line\nthird line")

Three lines in the payload become three data: lines, which is what the SSE specification requires:

print(multiline.render)

SseDataEncoder.stringChunk treats each element as a line and splits each element as well, so a chunk whose elements themselves contain newlines still flattens correctly:

import zio.http.ServerSentEvent
import zio.blocks.chunk.Chunk

val chunked = ServerSentEvent(Chunk("alpha", "beta\ngamma"))

The two elements yield three lines:

print(chunked.render)

An empty Chunk renders as one empty data: line rather than none, matching the single-String behaviour for an empty payload.

Custom Instances

An encoder for your own type is one method. Returning several lines is how a structured payload spans multiple data: fields:

import zio.http.{ServerSentEvent, SseDataEncoder}
import zio.blocks.chunk.Chunk

final case class Progress(step: Int, total: Int, note: String)

implicit val progressEncoder: SseDataEncoder[Progress] =
  new SseDataEncoder[Progress] {
    def lines(value: Progress): Chunk[String] =
      Chunk(s"step=${value.step}/${value.total}", s"note=${value.note}")
  }

With the instance in implicit scope, the payload type flows through the envelope unchanged:

print(ServerSentEvent(Progress(3, 10, "compiling"), "progress").render)

:::warning[Split lines yourself, or don't produce them] ServerSentEvent#render prefixes each line the encoder returns with data: and does not inspect it. An encoder that returns a string containing \n therefore emits a malformed event. Either split within SseDataEncoder#lines, or delegate to SseDataEncoder.string for the parts that might contain breaks. :::

For a JSON payload, encode to a String with the JSON codec and reuse SseDataEncoder.string, which handles any newlines the rendered document contains.

Equality and Rendering as Text

ServerSentEvent#equals compares the payload and all three metadata fields, so two envelopes are equal when they would render identically. ServerSentEvent#hashCode agrees with it.

ServerSentEvent#toString is a diagnostic rendering, not the wire format — absent fields appear as null and no data: prefixes are added:

import zio.http.ServerSentEvent

val event = ServerSentEvent("payload").id("9")

Use ServerSentEvent#render for anything that goes over the wire and ServerSentEvent#toString only for logs:

event.toString

Integration Points

An SSE response is an ordinary Response whose body streams rendered events with a text/event-stream content type, so ServerSentEvent composes with the rest of the HTTP model rather than replacing any of it. Setting that content type is a Header.ContentType — see Header.

The type depends on Chunk for the line sequence and on Maybe for its optional fields, both from the core blocks: see Chunk and Maybe.