Skip to content

Real-time chat

These guides build a real-time multi-room chat application using Arc’s observable queries. They share a common shape, a ChatMessage read model with a ForRoom observable query, a SendMessage command, and a React page component, but each one explores a different dimension of the pattern.

The backends are shown in C#, Kotlin, Java, and TypeScript where Arc’s APIs differ. Pieces that are plain application code, such as the RabbitMQ consumer, are shown in C#, and the Incremental Pushes backend is shown in C# only, to keep that guide short. The React frontend is the same for every backend.

The simplest starting point. Each room holds its message history in an observable source with a current value, inside a single, application-wide ChatService. No external dependencies required.

Covers:

  • A source with a current value as the backing store for live chat state
  • Returning that source from an observable query, and the per-subscriber relay C# uses
  • How Arc’s delta mode works: the first emission delivers the full history; subsequent emissions deliver only the ChangeSet of new messages

Replaces the in-process state with two external systems: a persistence layer that loads message history on startup, and a RabbitMQ fanout exchange that delivers new messages to every server instance. The observable query and the React component are identical to the in-memory version.

Covers:

  • Loading initial history from a persistence layer
  • A broker consumer that routes messages to the correct ChatRoom
  • Publishing from SendMessage through a publishing contract rather than writing directly to the room
  • Scaling across multiple server instances

The backend is unchanged from the in-memory guide. The React component switches from ForRoom.use() to ForRoom.useChangeStream() to receive the raw ChangeSet ({ added, replaced, removed }) and manages its own useState accumulator.

Covers:

  • When to use useChangeStream() instead of use()
  • Appending ChangeSet.added items to local state
  • Deriving secondary state from the delta: scroll-to-bottom logic and an unread message counter

The backend changes fundamentally. ChatRoom becomes a pure pub/sub channel with no history that fires only new messages. ChatService owns the history. The ForRoom query emits the full history once as the initial payload, then forwards each new message individually, so the backend’s work per message stays constant regardless of conversation length. It does not shrink the delta-mode payload; the guide explains what Arc sends.

Covers:

  • Separating pub/sub (ChatRoom) from history (ChatService)
  • Sending the history and subscribing to the room in one step, so a joining subscriber neither misses nor repeats a message
  • Why use(), not useChangeStream(), is correct when the backend sends incremental payloads
  • A useEffect accumulator that appends both the initial history and each new arrival

The pattern is the same in every backend. These are the places where the code differs; the guides explain each one where it first appears.

C#KotlinJavaTypeScript (preview)
Source with a current valueSystem.Reactive BehaviorSubject<IEnumerable<ChatMessage>>MutableStateFlow<List<ChatMessage>>Arc’s ObservableState<List<ChatMessage>>RxJS BehaviorSubject<ChatMessage[]>
ForRoom returnsISubject<IEnumerable<ChatMessage>>, a relay per subscriberFlow<List<ChatMessage>>, the room’s sourceFlow.Publisher<List<ChatMessage>>, the room’s sourceThe room’s RxJS subject
ChatService as a service parameterResolved from dependency injection because its type is registeredMarked @FromServicesMarked @FromServicesListed as service(ChatService) in @query
One ChatService instancebuilder.Services.AddSingleton<ChatService>()@Component@Component@singleton()
Authorization on the query and commandNone@AllowAnonymous, because Arc on the JVM requires an authenticated caller by default@AllowAnonymous, for the same reasonNone
RabbitMQ publisher and consumerShown; the consumer is a BackgroundServiceUse your platform’s AMQP clientUse your platform’s AMQP clientUse your platform’s AMQP client
Incremental Pushes backendShown, with a System.Reactive Subject and Observable.CreateSame building block: a Flow built per subscriber, such as callbackFlowNot shownSame building block: an RxJS Observable built per subscriber

Choosing C# for the Incremental Pushes backend is a scope choice, not a limit of the other backends.

ForRoom returns an observable source of the room’s messages. That return type is the contract between the query and Arc. It does not change regardless of how the backend sources or stages its data, and the generated TypeScript proxy is identical across all four guides.

In memoryRabbitMQFrontend stateIncremental pushes
Backend emitsFull historyFull historyFull historyHistory once, then single messages
History lives inChatRoomChatRoomChatRoomChatService
C# relay typeBehaviorSubjectBehaviorSubjectBehaviorSubjectObservable.Create, one stream per subscriber
Backend work per messageGrowsGrowsGrowsConstant
Delta-mode payload per messageNew messageNew messageNew messageNew message + previous emission as removed
React hookuse()use()useChangeStream()use()
Component accumulatesNoNoYes, via addedYes, via data