Table of Contents

Streams, cancellation, and 0-RTT

Closing versus cancelling a stream, and the two ways to send data before a handshake finishes.

A Conn carries many streams. Getting their lifecycle right is most of what separates a protocol that works from one that hangs or wastes bandwidth. This page covers the parts that are easy to get wrong.

Upstream covers the same ground for Rust in Using QUIC.

Closing is not cancelling

Two different operations, routinely confused:

Call Meaning
Stream.Close() finish sending — the peer reads EOF after buffered data
Stream.CancelWrite(code) abandon sending — discard buffered data, tell the peer
Stream.CancelRead(code) stop receiving — tell the peer to stop sending

Close is graceful and belongs at the end of a successful exchange. It closes only the send side of a bidirectional stream, which is what lets the peer’s io.Copy or io.ReadAll return. Forgetting it is the most common way to hang a request/response protocol — see Build an echo protocol.

CancelWrite and CancelRead are the abort path. They exist because QUIC can stop a transfer that is no longer wanted:

s, err := conn.OpenStreamSync(ctx)
// ... decide the response is stale or the user navigated away
s.CancelRead(1)   // stop the peer sending more
s.CancelWrite(1)  // discard anything we have queued

The reason to reach for these rather than just closing the connection: on a multiplexed connection, a stream you no longer care about still consumes bandwidth and can delay the streams you do care about. Cancelling reclaims that capacity without disturbing the other streams. For anything real-time — media, interactive UI, speculative prefetch — this is the difference between a responsive protocol and one that falls behind under load.

Both take an application error code, an opaque uint64 your protocol defines. The unidirectional halves have the matching subset: SendStream.CancelWrite and ReceiveStream.CancelRead.

0-RTT: sending before the handshake completes

Normally Endpoint.Connect blocks until the peer is authenticated. Two APIs let you send earlier, and they are not symmetric.

Client side — ConnectEarly and Into0RTT

c, err := ep.ConnectEarly(ctx, addr, alpn)   // returns immediately
if err != nil {
	return err
}
conn, ok := c.Into0RTT()   // ok reports whether 0-RTT is actually available
if !ok {
	conn, err = c.Connection(ctx)  // fall back to the verified path
}

Into0RTT returns ok = false when there is no usable session ticket for this peer — the first connection to a peer can never be 0-RTT. Always write the fallback.

Those tickets come from iroh.SessionCache, an LRU of TLS 1.3 session tickets bucketed by TLS server name. Because iroh derives a unique server name from each peer’s endpoint ID, tickets for different peers cannot collide and a resumption always targets the identity you meant. You get this by default; there is nothing to wire up.

A Connecting is not safe for concurrent use and may be consumed only once: after Into0RTT succeeds or Connection returns, do not touch it again.

Server side — Accepting.Into0RTT

The accept side has the mirror image: an Accepting whose handshake may still be in flight, with Into0RTT() to get a connection that can read early data and Connection(ctx) to wait for the verified one.

The safety rule

Early data arrives before the peer is authenticated and can be replayed by an attacker who captured it. So handle it only if both hold:

A read is usually fine. A write, a payment, or anything authorizing on identity is not. When you need the guarantee, wait:

<-conn.HandshakeComplete()   // now RemoteID is proven

After the fact, conn.Used0RTT() reports whether the connection actually used early data, which is what you check before trusting anything received on it.

Next steps

Last updated: 2026-08-20