LiveKit docs › Data › Data tracks

---

# Data tracks

> Use data tracks to send low-latency, high-bandwidth data between participants.

## Overview

Data tracks provide low-latency, lossy transport for continuous data between participants in a LiveKit room. They're designed for use cases where staying realtime matters more than guaranteed delivery, such as streaming sensor readings, robot teleoperation commands, or realtime telemetry.

Data tracks prioritize realtime delivery, where each frame is sent once with no retransmission. Frames are reordered on the subscriber side, so they're delivered in the order they're published. For low-level control over individual packet delivery, use [data packets](https://docs.livekit.io/transport/data/packets.md).

Data tracks are more lightweight than media tracks to publish and subscribe to. There's no codec or processing overhead, so you can publish many data tracks per participant, such as one track per sensor or actuator. Once published, a data track is visible to all participants in the room, including those who connect after the track is published.

Data tracks support [end-to-end encryption](https://docs.livekit.io/transport/encryption.md). If E2EE is enabled for the room, data track frames are encrypted and decrypted automatically. Data tracks are also automatically re-published and re-subscribed to after a reconnection.

## Publishing data tracks

A participant must have the `canPublishData` [grant](https://docs.livekit.io/frontends/reference/tokens-grants.md) to publish data tracks.

A participant publishes a data track by providing a `name`. The name must be 1–256 characters and unique among that participant's published data tracks. After publishing, the participant receives a local data track object that can be used to push frames. LiveKit Server selectively forwards frames only to participants that subscribe, so bandwidth isn't wasted broadcasting to uninterested consumers.

**JavaScript**:

```typescript
const track = await room.localParticipant.publishDataTrack({
  name: 'my_sensor_data',
});

// Push data using the returned LocalDataTrack
const payload = new Uint8Array(256).fill(0xFA);
track.tryPush({ payload });

```

---

**Python**:

```python
track = await room
    .local_participant
    .publish_data_track(name="my_sensor_data")

payload = bytes([0xFA] * 256)
track.try_push(rtc.DataTrackFrame(payload=payload))

```

---

**Rust**:

```rust
let track = room
    .local_participant()
    .publish_data_track("my_sensor_data")
    .await?;

track.try_push(DataTrackFrame::new(vec![0xFA; 256]))?;

```

---

**C++**:

```cpp
std::shared_ptr<livekit::LocalDataTrack> track;
if (auto lp = room->localParticipant().lock()) {
  auto publish_result = lp->publishDataTrack("my_sensor_data");
  if (!publish_result) {
    const auto& error = publish_result.error();
    std::cerr << "Failed to publish data track: code=" << static_cast<std::uint32_t>(error.code)
              << " message=" << error.message << "\n";
    return;
  }

  track = publish_result.value();
}
else
{
  std::cerr << "Failed to get local participant\n";
  return;
}

livekit::DataTrackFrame frame;
frame.payload = std::vector<std::uint8_t>(256, 0xFA);

auto push_result = track->tryPush(frame);
if (!push_result) {
  const auto& error = push_result.error();
  std::cerr << "Failed to push data frame: code=" << static_cast<std::uint32_t>(error.code)
            << " message=" << error.message << "\n";
}

```

---

**Unity**:

```csharp
var publishInstruction = room.LocalParticipant.PublishDataTrack("my_sensor_data");
yield return publishInstruction;

if (publishInstruction.IsError) {
    Debug.LogError($"Failed to publish track: {publishInstruction.Error}");
    yield break;
}
var track = publishInstruction.Track;

var payload = new byte[256];
Array.Fill(payload, (byte)0xFA);
var frame = new DataTrackFrame(payload);
try {
    track.TryPush(frame);
} catch (PushFrameError e) {
    Debug.LogError($"Failed to push frame! {e.Message}");
}

```

When a data track is no longer needed, unpublish it to notify other participants and release resources:

**JavaScript**:

```typescript
await track.unpublish();

```

---

**Python**:

```python
track.unpublish()

```

---

**Rust**:

```rust
track.unpublish();

```

---

**C++**:

```cpp
track->unpublishDataTrack();

```

---

**Unity**:

```csharp
track.Unpublish();

```

### User timestamps

Each frame can carry an optional 64-bit user timestamp, which is an application-defined value set by the publisher. This is useful for measuring end-to-end latency and correlating frames with real-world events, which is especially important for robotics and telemetry use cases. In embedded applications, the timestamp can reflect when a sensor actually sampled the value rather than when the frame was sent.

Set the timestamp when pushing a frame on the publisher side:

**JavaScript**:

```typescript
track.tryPush({
  payload: sensorData,
  userTimestamp: BigInt(Date.now()),
});

```

---

**Python**:

```python
frame = rtc.DataTrackFrame(
    payload=sensor_data,
    user_timestamp=int(time.time() * 1000),
)
track.try_push(frame)

```

---

**Rust**:

```rust
let frame = DataTrackFrame::new(sensor_data).with_user_timestamp_now();
track.try_push(frame)?;

```

---

**C++**:

```cpp
auto user_timestamp = static_cast<std::uint64_t>(
    std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::system_clock::now().time_since_epoch()).count());

livekit::DataTrackFrame frame;
frame.payload = sensor_data;
frame.user_timestamp = user_timestamp;

auto push_result = track->tryPush(frame);
if (!push_result) {
  const auto& error = push_result.error();
  std::cerr << "Failed to push data frame: code=" << static_cast<std::uint32_t>(error.code)
            << " message=" << error.message << "\n";
}

```

---

**Unity**:

```csharp
var frame = new DataTrackFrame(sensor_data)
    .WithUserTimestampNow();
track.TryPush(frame);

```

On the subscriber side, read the timestamp from the received frame to calculate latency:

**JavaScript**:

```typescript
for await (const frame of stream) {
  if (frame.userTimestamp) {
    const latencyMs = Date.now() - Number(frame.userTimestamp);
    console.log(`Latency: ${latencyMs}ms`);
  }
}

```

---

**Python**:

```python
async for frame in subscription:
    if frame.user_timestamp is not None:
        latency_ms = int(time.time() * 1000) - frame.user_timestamp
        print(f"Latency: {latency_ms}ms")

```

---

**Rust**:

```rust
while let Some(frame) = stream.next().await {
    if let Some(latency) = frame.duration_since_timestamp() {
        println!("Latency: {:?}", latency);
    }
}

```

---

**C++**:

```cpp
const auto callback_id = room->addOnDataFrameCallback(
    "sensor-publisher", "my_sensor_data",
    [](const std::vector<std::uint8_t>& /*payload*/, std::optional<std::uint64_t> user_timestamp) {
      if (!user_timestamp) {
        return;
      }

      const auto now_us = static_cast<std::uint64_t>(
          std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::system_clock::now().time_since_epoch())
              .count());
      std::cout << "Latency: " << (now_us - *user_timestamp) << "us\n";
    });

// Later, when you no longer want frames:
room->removeOnDataFrameCallback(callback_id);

```

---

**Unity**:

```csharp
while (!subscription.IsEos) {
    var frameInstruction = subscription.ReadFrame();
    yield return frameInstruction;

    if (frameInstruction.IsCurrentReadDone) {
        var frame = frameInstruction.Frame;
        Debug.Log($"Latency: {frame.DurationSinceTimestamp()}ms");
    }
}

```

> ℹ️ **Timestamp considerations**
> 
> User timestamps rely on synchronized clocks between publisher and subscriber. The calculated latency is only as accurate as the clock synchronization between the two participants. For application-specific round-trip metrics, consider storing timestamps directly in the payload and matching request and response IDs.

### Handling push errors

`tryPush` is a non-blocking call that can fail in two cases:

- **Track unpublished**: The track has been unpublished or the room has disconnected.
- **Frame dropped**: The outgoing buffer is full and the frame can't be queued.

Because data tracks use lossy delivery, occasional dropped frames are expected under high load. Design your application to tolerate gaps rather than treating every drop as an error.

### Choosing a frame size

Frames can be any size, but larger frames are split into multiple WebRTC data channel packets for transmission. Because data tracks use lossy delivery, if any packet in a multi-packet frame is lost, the entire frame is lost. Smaller frames are more resilient to packet loss.

For best reliability, keep frame payloads under 1200 bytes to fit within a single data channel packet. If your data is larger, consider whether occasional frame loss is acceptable for your use case.

## Subscribing to data tracks

Any participant in a room can subscribe to data tracks published by other participants. Subscribe to a remote data track to receive its frames as they arrive.

> ℹ️ **Note**
> 
> Register room event handlers _before_ calling `room.connect()`. Events like `DataTrackPublished` can fire during the connection handshake, and handlers registered afterward miss them.

### Listening for published tracks

When a remote participant publishes a data track, your client is notified through a room event. In some SDKs, you can also query existing data track publications on a remote participant directly.

**JavaScript**:

```typescript
import { RoomEvent } from 'livekit-client';

room.on(RoomEvent.DataTrackPublished, (track) => {
  console.log(`${track.publisherIdentity} published "${track.info.name}"`);
});

room.on(RoomEvent.DataTrackUnpublished, (sid) => {
  console.log(`Data track ${sid} was unpublished`);
});

```

Each remote participant also exposes a `dataTracks` map you can use to look up tracks by name:

```typescript
// Get a track that's already published
const track = remoteParticipant.dataTracks.get('my_sensor_data');

// Or wait for it to be published
const track = await remoteParticipant.dataTracks.getDeferred('my_sensor_data');

```

---

**Python**:

```python
@room.on("data_track_published")
def on_data_track_published(track: rtc.RemoteDataTrack):
    print(f"{track.publisher_identity} published '{track.info.name}'")

@room.on("data_track_unpublished")
def on_data_track_unpublished(sid: str):
    print(f"Data track {sid} was unpublished")

```

---

**Rust**:

```rust
while let Some(event) = rx.recv().await {
    match event {
        RoomEvent::DataTrackPublished(track) => {
            println!(
                "{} published '{}'",
                track.publisher_identity(),
                track.info().name()
            );
        }
        RoomEvent::DataTrackUnpublished(sid) => {
            println!("Data track {sid} was unpublished");
        }
        _ => {}
    }
}

```

---

**C++**:

```cpp
class DataTrackRoomDelegate : public livekit::RoomDelegate {
public:
  void onDataTrackPublished(livekit::Room&, const livekit::DataTrackPublishedEvent& event) override {
    std::cout << event.track->publisherIdentity() << " published '" << event.track->info().name << "'\n";
  }

  void onDataTrackUnpublished(livekit::Room&, const livekit::DataTrackUnpublishedEvent& event) override {
    std::cout << "Data track " << event.sid << " was unpublished\n";
  }
};

DataTrackRoomDelegate delegate;
room->setDelegate(&delegate);

```

---

**Unity**:

```csharp
room.DataTrackPublished += (track) => {
    Debug.Log($"{track.PublisherIdentity} published '{track.Info.Name}'");
};

room.DataTrackUnpublished += (sid) => {
    Debug.Log($"Data track {sid} was unpublished");
};

```

### Reading frames

Once you have a reference to a `RemoteDataTrack`, call `subscribe()` to begin receiving frames. This returns a stream that yields `DataTrackFrame` objects. In C++, `RemoteDataTrack::subscribe()` returns a blocking `DataTrackStream`, so read it from an application-owned background thread.

For convenience, C++ also supports Room::addOnDataFrameCallback(), which handles track publish events, subscription, and callback threading for you. Pass the publisher identity and data track name and LiveKit manages the rest. For example, the [`ping_pong_ping`](https://github.com/livekit-examples/cpp-example-collection/tree/main/ping_pong/ping) and [`ping_pong_pong`](https://github.com/livekit-examples/cpp-example-collection/tree/main/ping_pong/pong) examples use this callback-based approach.

**JavaScript**:

```typescript
const stream = track.subscribe();

for await (const frame of stream) {
  console.log('Received frame:', frame.payload);
}

```

---

**React**:

```typescript
// Assuming `track` is a `RemoteDataTrack`:

useEffect(() => {
  const controller = new AbortController();
  const stream = track.subscribe({ signal: controller.signal });

  (async () => {
    for await (const frame of stream) {
      console.log('Received frame:', frame.payload);
    }
  })();

  return () => {
    controller.abort();
  };
}, [track]);

```

---

**Python**:

```python
stream = track.subscribe()
async for frame in stream:
    print(f"Received frame: {frame.payload}")

```

---

**Rust**:

```rust
let mut stream = track.subscribe().await?;

while let Some(frame) = stream.next().await {
    println!("Received frame: {:?}", frame.payload());
}

```

Dropping the `stream` closes that subscription. If no other subscriptions remain on the same track, the underlying connection to the server is also closed.

---

**C++**:

```cpp
const auto callback_id = room->addOnDataFrameCallback(
    "sensor-publisher", "my_sensor_data",
    [](const std::vector<std::uint8_t>& payload, std::optional<std::uint64_t> /*user_timestamp*/) {
      std::cout << "Received frame with " << payload.size() << " bytes\n";
    });

// Later, when you no longer want frames from this data track:
room->removeOnDataFrameCallback(callback_id);

```

---

**Unity**:

```csharp
var stream = track.Subscribe();
while (!stream.IsEos) {
    var frameInstruction = stream.ReadFrame();
    yield return frameInstruction;

    if (frameInstruction.IsCurrentReadDone) {
        var frame = frameInstruction.Frame;
        Debug.Log($"Received frame: {BitConverter.ToString(frame.Payload)}");
    }
}

```

### Handling multiple subscriptions

You can call `subscribe()` more than once on the same track to fan out frames to multiple consumers. For example, one task could log data while another renders it. Internally, only the first call triggers server signaling, while subsequent calls reuse the existing subscription pipeline.

New subscriptions only receive frames published _after_ the subscription is established.

In C++, each room-level callback registered with `addOnDataFrameCallback()` creates an independent subscription and reader thread for that callback. Remove the callback with `removeOnDataFrameCallback()` when you no longer need it.

### Configuring buffer size

Each data track subscription independently maintains an internal buffer of frames. When frames arrive faster than they're consumed, the buffer fills up and additional frames are dropped. In C++, this option applies to `RemoteDataTrack::subscribe(...)`.

Choosing the right buffer size depends on your use case:

- A buffer that's too small drops frames frequently, even during brief processing pauses. This can cause gaps in sensor data or missed commands.
- A buffer that's too large allows memory usage to grow without limit if the consumer can't keep up. This is especially dangerous for long-running applications on lower-memory devices like robots or IoT hardware.

The default buffer size is 16 frames. This is reasonable for low- or moderate-frequency data, but for high-frequency use cases (hundreds of frames per second or more) it's likely not sufficient. Measure your publisher's data rate and your subscriber's consumption rate under realistic conditions, then choose a buffer size that absorbs normal jitter without growing indefinitely.

> ℹ️ **C++ reader threads**
> 
> In C++, audio, video, and data subscription callbacks use per-subscription reader threads. The SDK allows up to 20 active reader threads across audio, video, and data subscriptions. If you exceed this limit, the SDK will ignore the new subscription request. This is not currently configurable.

**JavaScript**:

```typescript
const stream = track.subscribe({ bufferSize: 64 });

```

---

**Python**:

```python
stream = track.subscribe(buffer_size=64)

```

---

**Rust**:

```rust
let mut stream = track
    .subscribe_with_options(DataTrackSubscribeOptions::new().with_buffer_size(64))
    .await?;

```

---

**C++**:

```cpp
livekit::DataTrackStream::Options options;
options.buffer_size = 64;

auto sub_result = track->subscribe(options);
if (!sub_result) {
  const auto& error = sub_result.error();
  std::cerr << "Subscribe failed: code=" << static_cast<std::uint32_t>(error.code) << " message=" << error.message
            << "\n";
  return;
}

std::shared_ptr<livekit::DataTrackStream> stream = sub_result.value();

```

---

**Unity**:

```csharp
var options = new DataTrackSubscribeOptions { BufferSize = 64 };
var stream = track.Subscribe(options);

```

---

This document was rendered at 2026-06-07T11:34:12.753Z.
For the latest version of this document, see [https://docs.livekit.io/transport/data/data-tracks.md](https://docs.livekit.io/transport/data/data-tracks.md).

To explore all LiveKit documentation, see [llms.txt](https://docs.livekit.io/llms.txt).