Skip to main content
Preview release. Data Streams are in Public Preview. They require Ditto SDK 5.2.0 or later and are available for Swift (iOS and macOS) and Kotlin (Android). The API may receive breaking changes without notice, and you must opt in to it in code — see Opt in to the preview API.

What are Data Streams?

A Data Stream is a low-latency, two-way connection that carries raw bytes from one Ditto peer to another. A peer is any running copy of your app that uses Ditto, such as on a phone, tablet, or laptop. Each stream is opened on a named topic, a short label that both peers agree on, similar to a channel name.

How streams reach other peers

Ditto peers connect to each other over whatever is available nearby, such as Bluetooth Low Energy or a local Wi-Fi network. Together, these connected peers form a mesh: a network in which peers can pass data along for each other, so it isn’t limited to the devices that are directly connected. To learn more, see Mesh Networking. A stream connects two peers even when they aren’t directly connected. For example, if your app on device A can reach device C only through device B, Ditto sends the stream through B for you. Your code names the peer it wants to reach, and Ditto finds the path. The devices in between only relay the bytes. Their apps don’t need to do anything, and unlike multi-hop sync, they don’t store any of the data.
Device A and device C are out of range of each other but both are in range of device B. Bytes travel from A through B to C, and back from C through B to A. B relays the bytes and stores nothing.Device A and device C are out of range of each other but both are in range of device B. Bytes travel from A through B to C, and back from C through B to A. B relays the bytes and stores nothing.

Device A connects to device C. They're out of range of each other, so Ditto routes the stream through device B, which passes the bytes along in both directions without storing them.

Data Streams are built on Ditto’s Next Generation Networking (NGN), a new networking layer that finds routes like this across the mesh. You don’t call NGN directly. Its name shows up only in configuration, such as the ngn_data_streams_enabled setting you add during setup. 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.
Two rows compare document sync and Data Streams across devices A, B and C. In the document sync row, a document written on A is copied to B and then C, and each device keeps it in its local store. In the Data Streams row, bytes flow from A through B to C, and every device's store stays empty.Two rows compare document sync and Data Streams across devices A, B and C. In the document sync row, a document written on A is copied to B and then C, and each device keeps it in its local store. In the Data Streams row, bytes flow from A through B to C, and every device's store stays empty.

Document sync keeps a copy of the data on every peer that subscribes. Data Streams deliver the bytes without storing them on any peer.

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 table covers the terms used throughout the rest of this guide. For terms used across all of Ditto, see the Glossary.

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.
Let the connecting peer send first. When the binding peer opens a stream, the connecting peer may not be ready to receive yet. A message the binding peer sends right after opening its side can be lost, even on a reliable stream. Have the binding peer wait until the connecting peer has sent something before it sends, as the take() example does. The connecting peer can send as soon as connect returns.
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.2.0 or later
  • A Swift (iOS or macOS) or Kotlin (Android) app that already initializes Ditto — see the Swift or Kotlin install guide
  • Two devices, simulators, 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 must opt in. In Kotlin, add a file-level opt-in annotation. In Swift, import DittoSwift with the PreviewDataStreams SPI — without it, the Data Streams types aren’t visible.
3

Turn on Data Streams when Ditto opens

Data Streams are off by default. Turn them on by adding the ngn_data_streams_enabled system parameter to the DittoConfig you already create in your install-guide setup:
  • The parameter is read once, when the Ditto instance opens. Setting it later has no effect.
  • Document and attachment sync are unaffected: they keep using your existing transports. For this preview release, leave the related ngn_sync_enabled parameter at its default (false).
SDK 5.1 used the DITTO_NETWORK_ENABLE_NGN and DITTO_REPLICATION_OVER_NGN environment variables for this. They still work in 5.2 but are deprecated; use the system parameter instead.
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 (after the authentication setup from the install guide):
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 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 closure you pass to open (the onReceive parameter) — runs on the connection’s driver thread. Do only non-blocking work and return quickly, or you risk delayed messages and disconnections. The onReading callback above must therefore be non-blocking: for example Channel.trySend or MutableSharedFlow.tryEmit in Kotlin, or an AsyncStream continuation’s yield in Swift.
Swift: keep a strong reference to every stream you open, as the session above does. A DittoStream that nothing references is released immediately and stops delivering messages.
Keep the SensorReceiverSession (and therefore its acceptor) 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 waits for and lends an accepted candidate to your closure — the candidate is not opened for you, so your closure must open it. The SDK closes the candidate when the closure returns; the stream you opened from it stays open. (In Kotlin, call take() first if you want to keep the candidate itself beyond the closure.) Get receiverPeerKey from presence (the receiver’s peer key), 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 (for example, on another coroutine or task), call take() and close the returned candidate yourself:
The binding peer replies only after the connecting peer’s first message arrives. Sending right after opening the stream is a race: the message can arrive before the connecting peer is ready and be lost (see How a stream works).

Send modes and maxSendSize

Whatever you send, first make sure the payload fits in a single send. A payload larger than the stream’s maxSendSize is rejected, so check it before sending. Unreliable streams carry each payload in a single network packet, so their limit is small (on the order of a kilobyte); reliable streams accept far larger payloads.
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 in Kotlin, Data in Swift).

1. send — await delivery status

Use this when you want to know how the send finished. It suspends (Kotlin) or awaits (Swift) until 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 report a status, so it has the least overhead.

3. send with a monitoring closure — track progress

Use this when you need to observe the send as it progresses, such as implementing backpressure. You pass a closure that receives a DittoSendOperation; the closure 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 the unknown status 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

Every Data Streams handle — acceptors, candidates, and streams — has a close() method (and in Kotlin is AutoCloseable, so use { } works). Retain acceptors and streams in a clear owner — like the SensorReceiverSession and SensorSenderSession above — and close them explicitly. 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. To stop sends, drain the queue, and wait for closure, use closeSync() in Kotlin or closeAndWait() in Swift. waitUntilClosed() only observes closure and can wait indefinitely. On shutdown, cancel jobs or tasks, close streams, close acceptors, then close (Kotlin) or release (Swift) 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 cancellation — rethrow CancellationException in Kotlin, and stop retrying when Task.isCancelled is true in Swift — and use bounded exponential backoff with jitter when retrying failed connections. An in-flight cancellation can surface as a Data Streams error rather than a cancellation error, so check for cancellation before you retry. 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 start the connect from a separate coroutine or task. Presence callbacks are not the place to run an asynchronous connection, and connecting inline can block presence delivery or overlap with other callbacks for the same peer.

API at a glance

The two SDKs share the same types. Kotlin exposes most accessors as functions, Swift as (throwing) properties.

AI Agent Prompts

If you use an AI coding assistant, expand and copy the prompt for your platform. Each is a self-contained skill for writing correct Ditto Data Streams code against the 5.2.0 preview API — the concepts on this page, the ownership and lifecycle rules, and a review checklist, in one block.

Limitations and preview caveats

  • Requires Ditto SDK 5.2.0 or later, on Swift (iOS and macOS) or Kotlin (Android).
  • You must opt in to the preview API: @file:OptIn(com.ditto.kotlin.PreviewDataStreams::class) in Kotlin, @_spi(PreviewDataStreams) import DittoSwift in Swift.
  • Data Streams must be turned on with the ngn_data_streams_enabled system parameter when Ditto opens.
  • 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.
  • A message the binding peer sends right after opening a stream can be lost, because the connecting peer may not be ready yet. Let the connecting peer send first.
  • Streams carry raw bytes and are never persisted to the Ditto store.
  • We recommend leaving ngn_sync_enabled at its default (false) for this preview release.
  • The API may change during Preview.

Next steps