DEV Community

Cover image for Building a Distributed Chat App in Spring Boot (No Redis Required)
Kim Seon Woo
Kim Seon Woo

Posted on

Building a Distributed Chat App in Spring Boot (No Redis Required)

Most tutorials on building distributed chat systems start with "First, install Redis..." or "Set up RabbitMQ..." But what if you didn't need any of that?

Here's how to build a production-ready distributed chat system using just Spring Boot and actors. No external message brokers. No Redis for pub/sub. Just clean code that scales across multiple nodes.

What We're Building

A real-time chat system where:

  • Users connect via WebSocket
  • Rooms are automatically distributed across cluster nodes
  • Messages broadcast instantly to all room participants
  • Everything scales horizontally without configuration changes

The architecture uses three key pieces: sharded actors for chat rooms, regular actors for user connections, and pub/sub topics for message distribution.

Step 1: The Chat Room Actor

Each chat room is a sharded actor. Sharding automatically distributes rooms across cluster nodes—create 1000 rooms, they'll spread across your nodes automatically.

@Component
public class ChatRoomActor implements SpringShardedActor<ChatRoomActor.Command> {

    public static final EntityTypeKey<Command> TYPE_KEY =
        EntityTypeKey.create(Command.class, "ChatRoomActor");

    private final SpringTopicManager topicManager;

    public ChatRoomActor(SpringTopicManager topicManager) {
        this.topicManager = topicManager;
    }

    @Override
    public EntityTypeKey<Command> typeKey() {
        return TYPE_KEY;
    }

    @Override
    public SpringShardedActorBehavior<Command> create(SpringShardedActorContext<Command> ctx) {
        String roomId = ctx.getEntityId();

        // Create a pub/sub topic for this room using SpringTopicManager
        SpringTopicRef<UserActor.Command> roomTopic = topicManager
            .topic(UserActor.Command.class)
            .withName("chat-room-" + roomId)
            .getOrCreate();

        return SpringShardedActorBehavior.builder(Command.class, ctx)
            .withState(behaviorCtx -> new ChatRoomBehavior(behaviorCtx, roomTopic))
            .onMessage(JoinRoom.class, ChatRoomBehavior::onJoinRoom)
            .onMessage(SendMessage.class, ChatRoomBehavior::onSendMessage)
            .build();
    }
}
Enter fullscreen mode Exit fullscreen mode

The critical part: each room creates its own pub/sub topic. When a message arrives, broadcast it to the topic. Everyone subscribed receives it automatically.

private static class ChatRoomBehavior {
    private final SpringTopicRef<UserActor.Command> roomTopic;

    private Behavior<Command> onJoinRoom(JoinRoom msg) {
        SpringActorRef<UserActor.Command> userRef =
            new SpringActorRef<>(ctx.getSystem().scheduler(), msg.userActorRef);

        // Subscribe user to room's topic
        roomTopic.subscribe(userRef);

        // Notify everyone
        roomTopic.publish(new UserActor.JoinRoomEvent(msg.userId));
        return Behaviors.same();
    }

    private Behavior<Command> onSendMessage(SendMessage msg) {
        // Broadcast to all subscribers
        roomTopic.publish(new UserActor.SendMessageEvent(msg.userId, msg.message));
        return Behaviors.same();
    }
}
Enter fullscreen mode Exit fullscreen mode

No loops. No tracking who's in the room. Just publish to a topic.

Step 2: The User Actor

Each connected WebSocket gets a user actor. This actor subscribes to room topics and forwards messages to the WebSocket.

@Component
public class UserActor implements SpringActorWithContext<
    UserActor.Command, UserActor.UserActorContext> {

    public static class UserActorContext extends SpringActorContext {
        private final SpringActorSystem actorSystem;
        private final String userId;
        private final Sinks.Many<String> messageSink;  // WebSocket output

        public UserActorContext(SpringActorSystem actorSystem,
                                String userId,
                                Sinks.Many<String> messageSink) {
            this.actorSystem = actorSystem;
            this.userId = userId;
            this.messageSink = messageSink;
        }

        @Override
        public String actorId() {
            return userId;
        }
    }

    @Override
    public SpringActorBehavior<Command> create(UserActorContext actorContext) {
        return SpringActorBehavior.builder(Command.class, actorContext)
            .withState(ctx -> new UserActorBehavior(
                ctx, actorContext.actorSystem,
                actorContext.userId, actorContext.messageSink))
            .onMessage(JoinRoom.class, UserActorBehavior::onJoinRoom)
            .onMessage(SendMessageEvent.class, UserActorBehavior::onSendMessageEvent)
            .build();
    }
}
Enter fullscreen mode Exit fullscreen mode

The clever part: when the user actor receives an event from the pub/sub topic, it forwards it to the WebSocket sink:

private static class UserActorBehavior {
    private final Sinks.Many<String> messageSink;
    private String currentRoomId;

    private Behavior<Command> onJoinRoom(JoinRoom command) {
        currentRoomId = command.getRoomId();

        // Tell the room actor to subscribe us
        SpringShardedActorRef<ChatRoomActor.Command> roomActor =
            actorSystem.sharded(ChatRoomActor.class)
                .withId(currentRoomId)
                .get();

        roomActor.tell(new ChatRoomActor.JoinRoom(
            userId,
            context.getUnderlying().getSelf()  // Send raw ActorRef for cluster serialization
        ));

        return Behaviors.same();
    }

    private Behavior<Command> onSendMessageEvent(SendMessageEvent event) {
        // Received via pub/sub topic - forward to WebSocket
        String json = String.format(
            "{\"type\":\"message\",\"userId\":\"%s\",\"message\":\"%s\",\"roomId\":\"%s\"}",
            event.userId, event.message, currentRoomId
        );
        messageSink.tryEmitNext(json);
        return Behaviors.same();
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: WebSocket Handler

Connect the WebSocket to the actor system:

@Component
public class ChatWebSocketHandler implements WebSocketHandler {
    private final SpringActorSystem actorSystem;
    private final ConcurrentMap<String, SpringActorRef<UserActor.Command>> userActors =
        new ConcurrentHashMap<>();

    @Override
    public Mono<Void> handle(WebSocketSession session) {
        String userId = UUID.randomUUID().toString();

        // Create a sink for outgoing messages
        Sinks.Many<String> sink = Sinks.many().multicast().onBackpressureBuffer();

        // Create user actor with the sink
        UserActor.UserActorContext context =
            new UserActor.UserActorContext(actorSystem, userId, sink);

        return Mono.fromCompletionStage(actorSystem
                .actor(UserActor.class)
                .withContext(context)
                .spawn())
            .flatMap(userActor -> {
                userActors.put(userId, userActor);
                userActor.tell(new UserActor.Connect());

                // Handle incoming messages
                Mono<Void> input = session.receive()
                    .map(WebSocketMessage::getPayloadAsText)
                    .flatMap(payload -> handleMessage(userId, payload))
                    .then();

                // Send outgoing messages
                Mono<Void> output = session.send(
                    sink.asFlux().map(session::textMessage)
                );

                return Mono.zip(input, output)
                    .doFinally(signalType -> cleanup(userId))
                    .then();
            });
    }

    private Mono<Void> handleMessage(String userId, String payload) {
        return Mono.fromCallable(() -> {
            JsonNode json = objectMapper.readTree(payload);
            String type = json.get("type").asText();

            SpringActorRef<UserActor.Command> userActor = userActors.get(userId);
            if (userActor == null) return null;

            switch (type) {
                case "join":
                    String roomId = json.get("roomId").asText();
                    userActor.tell(new UserActor.JoinRoom(roomId));
                    break;
                case "message":
                    String message = json.get("message").asText();
                    userActor.tell(new UserActor.SendMessage(message));
                    break;
            }
            return null;
        })
        .subscribeOn(Schedulers.boundedElastic())  // Offload blocking JSON parsing
        .then();
    }

    private void cleanup(String userId) {
        SpringActorRef<UserActor.Command> userActor = userActors.remove(userId);
        if (userActor != null) {
            userActor.stop();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The Message Flow

Here's what happens when a user sends a message:

  1. WebSocket receives JSON → handleMessage() parses it
  2. Handler tells UserActor → "SendMessage" command
  3. UserActor tells ChatRoomActor → "SendMessage" command
  4. ChatRoomActor publishes to room topic
  5. All subscribed UserActors receive the event via pub/sub
  6. Each UserActor forwards to its WebSocket sink
  7. WebSocket sends JSON to browser

The pub/sub topic handles distribution automatically. Works the same whether actors are on the same node or different nodes.

What Makes This Work

Three features combine to create the full system:

Sharded Actors: Chat rooms are SpringShardedActor, so they're automatically distributed across cluster nodes. Create room "lobby" on node 1, room "random" ends up on node 2. You don't control it, you don't need to.

Pub/Sub Topics: Each room creates a topic. Publish once, all subscribers receive it. Topics work across cluster boundaries transparently.

Actor Context: UserActorContext passes the WebSocket sink into the actor. The actor can now write directly to the WebSocket without coupling to HTTP infrastructure.

Running It Locally

Start a 3-node cluster:

sh cluster-start.sh chat io.github.seonwkim.example.SpringPekkoApplication 8080 2551 3
Enter fullscreen mode Exit fullscreen mode

Start the frontend:

cd example/chat/frontend
npm run dev
Enter fullscreen mode Exit fullscreen mode

Open multiple browser tabs. Join the same room from different tabs. Send messages. They broadcast instantly.

Check the logs—you'll see rooms distributed across different nodes, but messages flow seamlessly.

Why This Matters

You just built a distributed chat system without:

  • Installing Redis for pub/sub
  • Setting up RabbitMQ or Kafka
  • Writing message routing logic
  • Managing WebSocket connections in a shared store
  • Dealing with sticky sessions or load balancer config

The actor model plus pub/sub topics give you distributed messaging built into the framework. Add nodes, rooms automatically distribute. Remove nodes, rooms migrate. The cluster handles it.

When to Use This Pattern

This works well for:

  • Chat applications (obviously)
  • Live notifications
  • Collaborative editing
  • Real-time dashboards
  • Multiplayer game lobbies
  • Any broadcast messaging scenario

Skip this if you need:

  • Message persistence (actors are in-memory)
  • Exactly-once delivery guarantees
  • Complex message routing logic
  • Integration with existing message brokers

The Source Code

Full implementation: Chat Example on GitHub

Complete documentation: spring-boot-starter-actor

Wrapping Up

The actor model isn't just for academics. Combine sharded actors for distribution, pub/sub topics for broadcasting, and WebSockets for client communication, and you get a chat system that scales horizontally without external dependencies.

No Redis. No RabbitMQ. No complex configuration. Just actors, topics, and clean code.

Try it yourself and see how much simpler distributed systems can be when the framework handles the hard parts for you.

Top comments (0)