Skip to content

Streaming responses

A handler can return a body that streams instead of one buffered whole in memory, or a live Server-Sent Events feed the browser reads with EventSource. #[routes] passes the handler’s return type through untouched, so any poem response type works — no wrapper, no schema requirement.

crates/features/src/audio/http/controller.rs (abridged)
#[get("/download")]
async fn download(&self, query: Valid<Query<TranscodeDto>>) -> Result<Response> {
let file = query.into_inner().file;
match self.svc.open_result(&file).await.map_err(internal)? {
Some(stream) => Ok(Response::builder()
.content_type("audio/mpeg")
// The object's bytes flow chunk by chunk — never whole in memory.
.body(Body::from_bytes_stream(stream))),
None => Err(Error::from_status(StatusCode::NOT_FOUND)),
}
}

poem::Body::from_bytes_stream turns any Stream<Item = Result<Bytes, E>> into a response body. Feed it a source that itself streams — the object-storage client’s get_stream hands back the S3 GetObject chunks directly, so a large download proxies through the server without ever sitting whole in process memory.

Return poem::web::sse::SSE for a long-lived text/event-stream. Each item the stream yields is an Event; the browser reconnects on drop.

crates/features/src/audio/http/controller.rs (abridged)
#[get("/events")]
async fn events(&self, query: Valid<Query<TranscodeDto>>) -> SSE {
let stream = /* a Stream<Item = Event> polling the job */;
SSE::new(stream).keep_alive(Duration::from_secs(15))
}
  • A streamed handler advertises no response body schema in the OpenAPI document — there is no Json<T> to derive one from.
  • SSE and streamed bodies are HTTP only; the WebSocket gateway is the push channel for two-way traffic.
  • Compression sits outside the handler, so a streamed body is still encoded if the client accepts it — usually you leave compression off for already-compressed media.