DEV Community

Cover image for Distributed Pub/Sub in Spring Boot Without the Headache
Kim Seon Woo
Kim Seon Woo

Posted on

Distributed Pub/Sub in Spring Boot Without the Headache

You're building a chat room. Or a notification system. Or anything that needs to broadcast messages to multiple subscribers. Five minutes in, you're already knee-deep in managing collections of actor references, handling subscription lifecycles, and wondering why this simple concept requires so much plumbing.

The publish-subscribe pattern solves this elegantly—when it's done right. But implementing distributed pub/sub from scratch means dealing with cluster coordination, message routing, and subscriber cleanup. Not exactly a fun afternoon.

This is why spring-boot-starter-actor includes a topic feature that gives you distributed pub/sub with three lines of code.

What You Usually End Up With

Here's the manual approach most people start with:

@Component
public class ChatRoomActor {
    private final Set<ActorRef<Message>> subscribers = new HashSet<>();

    public void addUser(ActorRef<Message> userActor) {
        subscribers.add(userActor);
    }

    public void broadcast(Message msg) {
        for (ActorRef<Message> subscriber : subscribers) {
            subscriber.tell(msg);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Works fine until:

  • You need to scale across multiple nodes (subscribers are on different machines)
  • An actor dies and leaves a dead reference in your set
  • You forget to unsubscribe someone and cause a memory leak
  • You realize you're reinventing pub/sub poorly

What It Should Look Like

With topics, broadcasting becomes trivial:

@Service
public class ChatService {
    private final SpringTopicManager topicManager;

    public void sendMessage(String roomId, String userId, String message) {
        SpringTopicRef<ChatMessage> roomTopic = topicManager
            .topic(ChatMessage.class)
            .withName("chat-room-" + roomId)
            .getOrCreate();

        roomTopic.publish(new ChatMessage(userId, message));
    }
}
Enter fullscreen mode Exit fullscreen mode

Three lines. No loops. No subscriber tracking. Works in both local mode and across a distributed cluster.

How It Works Under the Hood

The topic feature is built on three layers:

1. Topic Lifecycle Management

When you call topicManager.topic(MessageType.class).withName("name").getOrCreate(), the SpringTopicManager asks the RootGuardian actor to create or retrieve a topic. The RootGuardian maintains all top-level actors in your system and ensures topics are created exactly once.

Topics are identified by both name and message type:

// From DefaultRootGuardian.java
private String buildTopicActorName(String topicName, Class<?> messageType) {
    return "topic-" + topicName + "-" + messageType.getName().replace(".", "_");
}
Enter fullscreen mode Exit fullscreen mode

This means topic(String.class).withName("chat") and topic(Integer.class).withName("chat") are completely different topics. Type safety at the infrastructure level.

2. Pekko's Distributed Pub/Sub

Under the hood, each topic is a Pekko Topic actor. Pekko handles:

  • Distributing messages across cluster nodes
  • Cleaning up dead subscribers automatically
  • Routing messages efficiently
  • Maintaining subscriber lists per node

You publish a message. Pekko makes sure every subscriber gets it, no matter which node they're on.

3. Spring-Friendly API

SpringTopicRef wraps the Pekko topic with a clean API:

public class SpringTopicRef<T> {
    public void publish(T message) { ... }
    public void subscribe(SpringActorRef<T> subscriber) { ... }
    public void unsubscribe(SpringActorRef<T> subscriber) { ... }
}
Enter fullscreen mode Exit fullscreen mode

The generic type <T> prevents you from subscribing the wrong actor type to a topic at compile time. If your topic publishes ChatMessage, you can't accidentally subscribe an actor that expects UserMessage.

Putting It Together: Distributed Chat

Here's a complete chat room implementation using sharded actors and topics:

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

    private final SpringTopicManager topicManager;

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

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

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

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

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

        private Behavior<Command> onJoinRoom(JoinRoom msg) {
            // Subscribe the user actor to the room's topic
            SpringActorRef<UserActor.Command> userRef =
                new SpringActorRef<>(ctx.getSystem().scheduler(), msg.userActorRef);
            roomTopic.subscribe(userRef);

            // Broadcast join notification to all subscribers
            roomTopic.publish(new UserActor.JoinRoomEvent(msg.userId));
            return Behaviors.same();
        }

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

Each chat room actor creates its own topic. Users subscribe to the topic when they join. Messages get broadcast to everyone. That's it.

The chat room doesn't track user references. It doesn't loop through subscribers. It just publishes to a topic, and Pekko handles the rest—including routing messages across cluster nodes if your rooms are distributed.

When Topics Make Sense

Use topics when you need:

  • One-to-many broadcasting - chat rooms, notifications, live feeds
  • Decoupled publishers and subscribers - neither side knows about the other
  • Dynamic subscriptions - actors join and leave freely without manual tracking
  • Cluster-wide messaging - seamlessly works across distributed nodes

Skip topics for:

  • Point-to-point messaging - just use actor references directly
  • Guaranteed delivery - topics are at-most-once delivery
  • Request-response - use the ask pattern instead
  • Exactly-once semantics - you'll need additional infrastructure

The Honest Trade-Offs

Topics give you simplicity and distribution at the cost of delivery guarantees. Messages are delivered at-most-once—network issues or node failures can lose messages. There's also a small overhead compared to direct actor messaging.

For most broadcast use cases (chat, notifications, event streams), this is fine. You're trading perfect reliability for much simpler code that scales horizontally without thinking about it.

Getting Started

Add the dependency to your project:

dependencyManagement {
    imports {
        mavenBom("com.fasterxml.jackson:jackson-bom:2.17.3")
    }
}

// Spring Boot 3.2.x
implementation 'io.github.seonwkim:spring-boot-starter-actor_3:0.6.3'
Enter fullscreen mode Exit fullscreen mode

Enable actor support:

@SpringBootApplication
@EnableActorSupport
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}
Enter fullscreen mode Exit fullscreen mode

Inject SpringTopicManager and start publishing:

@Service
public class NotificationService {
    private final SpringTopicManager topicManager;

    public void broadcast(Notification notification) {
        topicManager.topic(Notification.class)
            .withName("system-notifications")
            .getOrCreate()
            .publish(notification);
    }
}
Enter fullscreen mode Exit fullscreen mode

Try the Chat Example

The library includes a working distributed chat application:

# Start a 3-node cluster
$ sh cluster-start.sh chat io.github.seonwkim.example.SpringPekkoApplication 8080 2551 3

# Run the frontend
$ cd example/chat/frontend
$ npm run dev
Enter fullscreen mode Exit fullscreen mode

Send messages between users across the cluster. Open the browser console to see which node each room lives on—rooms are distributed, but messages flow seamlessly.

Resources

Final Thoughts

Pub/sub topics aren't magic. They're Pekko's distributed Topic actor wrapped in a Spring-friendly API with lifecycle management through the RootGuardian. But that's all you need to stop writing manual subscription tracking code and let the framework handle distribution for you.

If you're building broadcast features—chat rooms, notifications, live feeds—topics will save you from a lot of error-prone plumbing. The code gets simpler, scales across nodes automatically, and you can focus on your actual business logic instead of debugging why subscriber cleanup isn't working.

Give it a try and see how much simpler your broadcast messaging becomes.

Top comments (0)