This guide covers the Android (Kotlin) Data Streams preview and requires
Ditto SDK 5.1.0. The code samples here are Kotlin-specific.
What are Data Streams?
A Data Stream is a low-latency, bidirectional, peer-to-peer channel that carries raw bytes between two reachable Ditto peers. Each stream is opened on a named topic — a short label that both peers agree on, similar to a channel name. “Point-to-point” here is logical, not necessarily a single physical hop. NGN resolves a next hop through its router, so a stream between two peers can traverse intermediate peers in the mesh to reach a peer that isn’t directly connected. Data Streams work differently from the document and attachment sync you may already know. Document sync converges structured data through the Ditto store: every peer that subscribes eventually sees the same data, and Ditto persists it for you. Data Streams are the opposite of that model — they carry raw bytes, are ephemeral, and never write anything to the Ditto store. Ditto doesn’t know or care what’s inside the bytes you send; your application defines its own message format on top of the channel, whether that’s Protobuf, JSON, or a custom raw frame format. To open a stream, one peer binds a topic — this makes it act like a server, listening for incoming connections. Another peer then connects to that topic — acting like a client. Once the connection is established, the relationship is symmetric: both sides can send and receive bytes for as long as the stream stays open.When to use Data Streams
Data Streams and document sync solve different problems, and most apps end up using both. Reach for Data Streams when:- You need real-time, high-frequency, or ephemeral data delivered to another reachable peer, such as sensor readings, telemetry, live cursor or position updates, or audio frames.
- The data doesn’t need to be stored, queried, or converged across the mesh — it just needs to get to the other peer, fast.
- You want to define your own wire format and manage delivery guarantees yourself.
- The data must be persisted, so peers can read it later, offline or otherwise.
- The data must be queried with DQL, or must converge consistently across every peer in the mesh, not just one direct link.
Core concepts
Data Streams introduce a small vocabulary of their own. This glossary covers the terms used throughout the rest of this guide.How a stream works, end to end
Before looking at any code, it helps to walk through the full lifecycle of a stream — who does what, and in what order.1
The receiving peer binds a topic
The peer that will receive the connection binds a topic and keeps the returned acceptor alive. Dropping the acceptor stops the topic from accepting new connections.
2
The sending peer discovers the receiver
The peer that will initiate the connection uses presence to discover the receiving peer’s public peer key.
3
The sender connects on the topic
The sender opens a connection to that peer key on the agreed-upon topic.
4
The receiver's bind callback fires
On the receiving side, the bind callback fires with a candidate. The candidate is only valid inside the callback, so the app either opens it into a stream immediately, or calls
take() to hold onto it and open it later.5
Both peers send and receive
Once open, the stream is a symmetric, bidirectional channel. Either peer can send raw byte payloads and receive them from the other side.
6
Either side closes the stream
When the exchange is finished, either peer can close the stream to release its resources.
Presence only indicates that a peer is nearby — it does not prove that peer has bound the topic yet. Because of this, initial connection attempts can fail while the app is still starting up. Treat connection failures as expected and retry rather than as a fatal error.
Reliable vs. unreliable streams
Every stream is opened as either reliable or unreliable, and both sides of the connection must agree on which one they’re using.
Neither mode guarantees once-only delivery, so your application must tolerate a message it has already seen. Choose reliable streams when ordering matters and a gap should break the exchange rather than be silently skipped — for example, control messages, RPC calls, or ordered file chunks. Choose unreliable streams when you’re sending a continuous flow of data where the newest message matters more than every message arriving — for example, sensor or audio frames, where a dropped frame is quickly superseded by the next one.
Prerequisites and setup
1
Check prerequisites
- Ditto SDK 5.1.0
- An Android project
- Two Android devices or emulators, so you can test a real connection between two peers
2
Opt in to the preview API
Data Streams are a preview API, so every file that calls into them needs a file-level opt-in:
3
Enable NGN before constructing Ditto
Data Streams require Ditto’s next-generation networking (NGN), which must be enabled before your
Ditto instance is constructed. Set both environment variables in Application.onCreate():DITTO_NETWORK_ENABLE_NGN=trueturns on the NGN transport that Data Streams run over. Data Streams do not work without it.DITTO_REPLICATION_OVER_NGN=falsekeeps your regular document and attachment sync unaffected.
For this preview release, we recommend setting
DITTO_REPLICATION_OVER_NGN to false.4
Start sync
Data Streams need sync running so presence can discover peers. Ditto’s stable peer-to-peer transports are enabled by default, so for Data Streams alone you only need to start sync:
Start sync before you rely on presence to discover peers.
Quickstart: stream sensor readings between two peers
This quickstart builds the two peer roles from the previous section into working code: one peer binds thesensor_data topic and receives readings, the other connects to it and sends them. Both sides use the Reliable reliability mode, and sensor_data satisfies the topic pattern ^[a-zA-Z0-9_ ]{1,15}$.
1
Receive: own the acceptor and its streams
On the peer that receives data, wrap the acceptor and every open stream in one
owner so shutdown can close them deterministically. Closing the acceptor stops
new connections but does not close streams that are already open, so the
session has to close those itself.
Keep the
SensorReceiverSession (and therefore its DittoAcceptor) alive for as long as you want to accept connections. Only one acceptor may bind a given topic at a time — close the existing session before binding the same topic again.2
Send: connect once, reuse one stream
On the peer that sends data, connect once and keep the stream open, then
reuse it for every reading. Opening a fresh connection per reading defeats the
high-frequency use case and invites overlapping connection attempts.Then connect once and stream readings for as long as the session is open:
connect is suspending and takes a continuation. It waits for and lends an accepted candidate to your block — the candidate is not opened for you. Call candidate.open() in the block, or take() the candidate to open it later. The SDK closes the candidate when your block returns unless you take() it, so take() if you want the stream to outlive the block. Get receiverPeerKey from presence (the receiver’s peerKeyString), and don’t call connect from inside the presence callback itself — see Cancellation and reconnect.Key patterns
Candidate ownership with take()
Candidates are callback-scoped and close when the callback returns. To use one after the callback (e.g. on another coroutine), call take() and close the returned candidate yourself:
Send modes and maxSendSize()
Whatever you send, first make sure the payload fits in a single send. A payload larger than maxSendSize() is rejected, so check it before sending:
payload: ByteArray.
1. send — await delivery status
Use this when you want to know how the send finished. It is a suspending call that returns once the message’s lifecycle ends, giving you a DittoSendStatus (for example Sent, Failed, or Cancelled). This is the default choice when you care whether a message made it out.
2. sendAndForget — fire-and-forget
Use this for high-frequency data where you don’t need per-message status and can tolerate the occasional drop — for example live sensor frames on an unreliable stream. It returns immediately and does not suspend or report a status, so it has the least overhead.
3. send with a monitoring block — track progress
Use this when you need to observe the send as it progresses, such as implementing backpressure. You pass a block that receives a DittoSendOperation; the block runs while the send is in flight, and its return value becomes the result of the call.
Backpressure
Use the monitoring overload’sawaitStatusChange() to slow a fast producer to what the link can carry. Treat DittoSendStatus.Unknown as an indeterminate terminal result (don’t loop on it), and treat Sent as “sent to the remote peer,” not proof the receiver’s app code processed it.
Closing resources
All Data Streams handles areAutoCloseable. Retain acceptors and streams in a clear owner — like the SensorReceiverSession and SensorSenderSession above — and close them explicitly (or with use). Closing an acceptor unbinds the topic but does not close streams that are already open, so an owner that holds both must close the streams itself. Use closeSync() to stop sends, drain the queue, and wait for closure; waitUntilClosed() only observes closure and can wait indefinitely. On shutdown, cancel jobs, close streams, close acceptors, then close Ditto.
Cancellation and reconnect
Data Streams do not reconnect automatically. If a stream closes — because the peer went away, the link dropped, or a reliable stream failed — it stays closed until your application opens a new one. Build reconnection into your own logic. Preserve coroutine cancellation (rethrowCancellationException) and use bounded exponential backoff with jitter when retrying failed connections. Avoid opening multiple simultaneous connects for the same peer and topic.
API at a glance
One-line summaries only (link out for full details later):AI Agent Prompt
If you use an AI coding assistant, expand and copy the prompt below. It is a self-contained skill for writing correct Ditto Data Streams code against the Kotlin 5.1.0 preview API — the concepts on this page, the ownership and lifecycle rules, and a review checklist, in one block.Copy AI Data Streams Prompt (Click to Expand)
Copy AI Data Streams Prompt (Click to Expand)
Limitations and preview caveats
- This guide covers the Android (Kotlin) preview; the samples are Kotlin-specific.
- Requires Ditto SDK 5.1.0 and the
@file:OptIn(com.ditto.kotlin.PreviewDataStreams::class)annotation. - Topics must match
^[a-zA-Z0-9_ ]{1,15}$. - The receive callback you pass to
openruns on the connection’s driver thread and must return quickly. - Streams carry raw bytes and are never persisted to the Ditto store.
- We recommend keeping
DITTO_REPLICATION_OVER_NGN=falsefor this preview release. - The API may change during Preview.