Skip to main content
Preview API. Data Streams are in Public Preview and may receive breaking changes without notice. You must acknowledge this in code by opting in with @file:OptIn(com.ditto.kotlin.PreviewDataStreams::class).
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.
Prefer document sync when:
  • 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.
The connecting peer’s requested reliability mode must match the reliability the topic was bound with. If they don’t match, the connection is rejected.

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=true turns on the NGN transport that Data Streams run over. Data Streams do not work without it.
  • DITTO_REPLICATION_OVER_NGN=false keeps 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:
If you do enable a listener, use updateTransportConfig as shown — it modifies a copy of the transport config already attached to your Ditto instance, so all of your existing legacy transport settings stay intact. Don’t construct a new DittoTransportConfig() from scratch to configure it — doing so disables every transport you don’t explicitly re-enable.
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 the sensor_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.
The receive callback — the lambda you pass to open (the onReceive parameter) — runs on the connection’s driver thread. Do only non-blocking work (for example, hand the bytes to a queue) and return quickly, or you risk delayed messages and disconnections. The onReading callback above must therefore be non-blocking, such as a Channel.trySend or MutableSharedFlow.tryEmit.
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:
There are three ways to send a payload. They are alternatives — pick the one that fits your use case; you do not call all three. Each accepts the same 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’s awaitStatusChange() 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 are AutoCloseable. 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 (rethrow CancellationException) and use bounded exponential backoff with jitter when retrying failed connections. Avoid opening multiple simultaneous connects for the same peer and topic.
Don’t call connect directly inside a presence-observer callback. Copy the peer key out of the observer and launch the connect from a separate coroutine. Presence callbacks are not the place to run a suspending connection, and connecting inline can block presence delivery or overlap with other callbacks for the same peer.

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.

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 open runs 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=false for this preview release.
  • The API may change during Preview.

Next steps