Sending GRIT Room Events with LiveKit SendData
- Published on
Junyoung Yang
GRIT is a real-time video study platform based on Spring Boot and LiveKit. Video and audio were handled by LiveKit, but Pomodoro timers and emoji reactions still needed a separate data flow inside the room.
This post summarizes how I handled those room events with LiveKit SendData.
Problem
LiveKit provides the WebRTC-based structure for video and audio delivery. But not every interaction in a study service is represented as a media track.
GRIT needed events such as:
- Sending emoji reactions
- Syncing Pomodoro timer state
These events are not video or audio themselves. They are data used to update the room UI.
One possible approach was to add a separate WebSocket. Media would be handled by LiveKit, while timers and reactions would be sent through a backend WebSocket.
But room connection and participant management were already handled on top of LiveKit. Adding another event channel meant that the client had to manage both a LiveKit connection and a WebSocket connection.
So in GRIT, I used LiveKit SendData to send data events inside the room.
Approach
LiveKit SendData can send data to participants in the same Room. In GRIT, clients do not publish data events directly. The server checks permission first, then sends the event to the room.
When issuing a LiveKit token, I allowed room join and media publish/subscribe, but blocked data publishing from the client.
token.addGrants(
new RoomJoin(true),
new RoomName(roomName(group.getCode())),
new CanPublish(true),
new CanSubscribe(true),
new CanPublishData(false)
);
With this setup, clients can join the LiveKit Room and use video/audio, but room events go through the server API. The server checks whether the requester belongs to the group, then sends data to the same room with RoomServiceClient.sendData.
An emoji reaction was sent like this:
{
"type": "reaction",
"emoji": "CLAP",
"emojiChar": "...",
"senderNickname": "jun0"
}
The Pomodoro timer event included the current state calculated from the server clock.
{
"type": "pomodoro.sync",
"timer": {
"status": "RUNNING",
"phase": "FOCUS",
"serverNow": "2026-04-14T10:00:00Z",
"focusEndsAt": "2026-04-14T10:25:00Z",
"breakEndsAt": null,
"pausedAt": null,
"focusMinutes": 25,
"breakMinutes": 5,
"currentRound": 1,
"totalRounds": 4
},
"senderNickname": "jun0"
}
The client checks type and handles reaction display and timer UI updates separately.
Implementation
sequenceDiagram
participant ClientA as Client A
participant Backend as Spring Boot Backend
participant LiveKit as LiveKit Room
participant ClientB as Client B
ClientA ->> Backend: Request reaction or Pomodoro action
Backend ->> Backend: Check group membership
Backend ->> Backend: Create event payload
Backend ->> LiveKit: sendData(roomName, json, RELIABLE)
LiveKit -->> ClientA: Receive reaction or pomodoro.sync
LiveKit -->> ClientB: Receive reaction or pomodoro.sync
ClientA ->> ClientA: Update UI
ClientB ->> ClientB: Update UI
For emoji reactions, the server checks the request and sends a reaction event. The message includes the emoji name, display character, and sender nickname.
For the Pomodoro timer, the server does not only send a simple "started" event. It calculates status, phase, end times, paused time, and current round from the server clock, then sends them as a pomodoro.sync event. This lets clients align their timer UI to server-provided values even if local clocks differ slightly.
Points Checked
When using SendData, I first defined the event format. As event types increase, the client and server need to interpret messages with the same rules. So I separated types such as reaction and pomodoro.sync, and separated handlers by type on the client.
Event delivery is also different from state storage. SendData is closer to real-time delivery, so it should not be treated as a way to restore all past events when a user joins later. Parts that need the current room state still need separate state management.
Takeaway
In GRIT, I used LiveKit not only for video and audio delivery, but also for data events inside the room through SendData. The server checks group permission and sends reaction and pomodoro.sync events to the same Room.
SendData works for event delivery, but it does not replace state storage. Parts that need the current room state still need separate state management.