Real-time systems and event-driven architectures shape much of the modern digital experience. When I need an application to react instantly—whether to a financial trade, a sensor reading, or a chat message—I turn to specific patterns in Java. These approaches help me build systems that are not just fast, but also resilient and scalable under pressure. Let’s walk through some practical techniques I use.
I often start with WebSockets when I need a persistent, two-way conversation between a client and server. Think of a live sports score update or a collaborative document editor. The traditional web request-response cycle is too slow and clunky for these tasks. With WebSockets, I open a single connection that stays alive, allowing data to flow back and forth freely.
Setting this up in Spring Boot is straightforward. I create a configuration class to register a handler for a specific endpoint, like /ws. The handler manages the lifecycle of each connection. When a new client connects, I add its session to a list. When that client sends a message, I can process it and immediately broadcast a response to every other connected session. This eliminates the need for the client to constantly ask, “Is there new data?” The server can push it the moment it’s ready.
A simple handler keeps track of active sessions. I use a thread-safe list because multiple clients can connect and disconnect at any moment. In the afterConnectionEstablished method, I store the new session. In handleTextMessage, I take the incoming text and send it back out to everyone. This creates a basic echo chamber, which is the foundation for a chat room. For a production system, I’d add more: heartbeats to detect dead connections, graceful reconnection logic, and security to validate each message.
For situations where the communication is mostly one-way—like a live news ticker or a dashboard showing server metrics—I use Server-Sent Events, or SSE. It’s a simpler protocol built on top of HTTP. The client makes a single request, and the server keeps the connection open, sending a stream of text events down the line until it’s done.
In a Spring controller, I can create an endpoint that returns a Flux of ServerSentEvent objects. The TEXT_EVENT_STREAM_VALUE media type tells the framework to handle the response correctly. I can use a reactive stream that emits an event every second, perhaps with a simulated stock price. Each event has an ID, a name, and the data payload. The browser’s JavaScript API for SSE is very simple; it listens for these named events and updates the webpage accordingly.
The beauty of SSE is its robustness. If the network drops, the client will automatically try to reconnect. The protocol includes a mechanism to send the last received event ID, so the server can pick up where it left off. It’s a fantastic, lightweight tool for broadcasting live updates without the complexity of managing a full WebSocket connection.
When the volume of events grows from dozens to millions per second, I need a different kind of engine. This is where a system like Apache Kafka comes in. It acts as a central nervous system for events, a highly durable log that anything can write to and anything can read from. I use it to decouple services completely. A service that generates user activity events doesn’t need to know which other services are interested; it just publishes them to a Kafka topic.
Setting up a Kafka producer in Spring involves configuring the connection to the Kafka cluster and serializers for the keys and values. Once I have a KafkaTemplate, sending an event is an asynchronous operation. I can add callbacks to log success or handle failures. On the other side, a consumer subscribes to a topic. I can configure it to process messages in batches for efficiency, reading hundreds of records at once instead of one by one.
The power of Kafka lies in its guarantees and scalability. Messages within a partition are ordered, and they’re stored durably. If a consuming service crashes, it can restart and continue reading from where it left off. This makes it perfect for building pipelines where data must not be lost, like processing financial transactions or aggregating user behavior for analytics.
Sometimes, I don’t just want to process events; I want to use them as the primary source of truth. This is the idea behind event sourcing. Instead of storing the current state of an object (like an account balance), I store every single change that happened to it as an immutable event. The current state is just the sum of all those past events.
In code, my Account class has a balance field, but it also maintains a list of uncommitted changes. When I call deposit(amount), I don’t just add to the balance. I create a MoneyDeposited event object and apply it. The apply method does two things: it adds the event to the change list, and it calls a private method to update the actual balance. Later, I can persist these events to a database.
The real magic happens when I need to recreate the object. I can write a static factory method, fromHistory, that takes a list of past events. It creates a new Account instance and replays each event, calling the same state-mutation logic. This gives me a complete audit trail for free. I can see the entire history of the account. The challenge is querying; to find all accounts with a balance over $1000, I’d have to replay every event for every account. That’s why I often pair event sourcing with a separate “read model”—a database table optimized for those kinds of queries, updated asynchronously as events occur.
All this streaming data becomes most valuable when I can visualize it immediately. Building a real-time dashboard means processing windows of data. For example, I might want to see the average temperature from a sensor over the last five seconds, updated every second.
I can use a reactive streams library like Project Reactor to model this. I create a stream of sensor readings, then use a sliding window operation. The window function groups the readings into buckets: each bucket contains five seconds of data, and a new bucket starts every second. I then flatMap each window, calculating the average of the readings inside it. This gives me a new stream of average values, emitted every second.
I need to get these calculated averages to a web browser. I might store the latest value in a fast, in-memory store like Redis, keyed by sensor ID. Then, a separate WebSocket connection can push any new average value to the dashboard the moment it’s calculated and stored. This creates a pipeline: sensor -> stream processor -> Redis -> WebSocket -> browser. Each piece is focused and efficient.
In any fast-moving system, there will be moments when data arrives quicker than I can handle it. This mismatch is called backpressure, and ignoring it can cause memory errors and crashes. I need explicit strategies to manage the flow.
Using a library like RxJava, I can apply backpressure operators to my streams. The onBackpressureBuffer operator tells the stream to hold onto items in a queue if the downstream consumer is slow. I can set a maximum capacity for that queue. If it overflows, I have to decide what to do: drop the newest item, drop the oldest item, or fail. For a real-time dashboard showing the latest CPU usage, dropping old data might be acceptable. For processing financial orders, it would not be.
A more interactive approach is reactive pull. The consumer, or subscriber, actively requests a certain number of items when it’s ready for them. In its onSubscribe method, it receives a Subscription object. It might start by requesting 10 items. Each time it finishes processing one item in onNext, it can request one more. This puts the consumer in complete control of the pace, ensuring it never gets overwhelmed.
Sometimes, a single event isn’t interesting, but a sequence or pattern of events is. If a temperature sensor reads over 100 degrees three times in a row, I need to sound an alarm. Writing this pattern-detection logic by hand is tedious and error-prone. This is where Complex Event Processing libraries help.
A CEP engine like Esper lets me define patterns using a SQL-like language. I configure the engine to recognize my event class, TemperatureEvent. Then I define a pattern: “Find every sequence where event A has temperature > 100, followed by event B with temperature > 100, followed by event C with temperature > 100.” I register this pattern as a statement and attach a listener.
Now, I just feed individual events into the engine’s runtime. The engine maintains the necessary state internally. When it sees the three high-temperature readings in sequence, it triggers my listener, passing the three matching event objects. I can then execute my alarm logic. This approach allows me to express very sophisticated temporal and logical relationships between events declaratively, without managing complex state machines in my business logic.
When my real-time system scales out to multiple servers, a new problem emerges: shared state. If two instances are processing events for the same user session, how do they keep that session’s state consistent? A distributed data grid can solve this.
Hazelcast is a popular choice. I start a Hazelcast instance in each of my application servers, and they form a cluster. I can get a distributed IMap that behaves like a ConcurrentHashMap but is shared across every node in the cluster. To update a user’s session state, I use executeOnKey. This ensures that the update logic runs atomically on the node that holds the primary copy of that specific key, preventing race conditions.
I can also add listeners to the map. If a session is updated on Node A in the cluster, my listener on Node B can be notified immediately. This allows all nodes to react to state changes, enabling features like broadcasting a “user is typing…” notification to all participants in a chat room, no matter which server they’re connected to. The trade-off is latency and complexity; communicating across a network is slower than local memory access.
For the most demanding applications, like high-frequency trading platforms, every microsecond counts. Standard messaging layers involve object creation, serialization, and garbage collection, which introduce too much delay. In these cases, I look to ultra-low-latency libraries.
Aeron is a message transport built for this purpose. It uses a publisher-subscriber model over shared memory or network. The code looks different. I work with UnsafeBuffer objects that wrap direct memory (ByteBuffer.allocateDirect), avoiding the Java heap and thus garbage collection. I put my message bytes directly into this buffer.
The Publication object’s offer method tries to send the buffer. If it returns a negative number, it means the channel is congested, and I might need to wait and try again. The subscriber reads messages directly from the buffers. This entire pathway is designed for minimal latency, often in the microsecond range. It’s a specialized tool, but for the right problem, it’s indispensable.
With all these moving parts—streams, events, distributed state—things will go wrong. Proactive monitoring is not a luxury; it’s a requirement. I instrument every critical path.
I use a metrics library like Micrometer to time operations. At the start of processing an event, I start a timer. When processing finishes, I stop it and record the duration, tagging it with the event type. This lets me see if certain events are taking longer to process than others. I also count events as they enter the system, giving me a throughput measurement.
For understanding the journey of a single event through a maze of services, I use distributed tracing. I generate a unique trace ID at the entry point (like when a request hits my API gateway) and propagate it through every Kafka message, RPC call, and database query. Tools like Zipkin can collect these traces and show me a visual timeline. I can instantly see if a delay is happening in the Kafka consumer, in a database query, or in a call to an external service. This visibility is what turns a mysterious production slowdown into a solvable engineering problem.
Building these systems is a constant balance. I’m balancing speed against reliability, simplicity against power. There’s no single right answer. A chat application, a stock trading system, and a factory sensor network all have different needs. The techniques I’ve described are tools. WebSockets, SSE, Kafka, event sourcing—each is perfect for a specific range of problems.
The most important lesson I’ve learned is to start simple. I might begin with a direct WebSocket connection and a simple in-memory event processor. As the load and complexity grow, I introduce a message broker like Kafka to decouple components. If I need an audit trail, I layer in event sourcing. If I need to detect fraud patterns, I integrate a CEP engine. Each step is a deliberate choice to solve a concrete problem, not just to use cool technology. The goal is always the same: to build a system that reacts to the present moment, reliably and at scale.