Atmosphere
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
Atmosphere is a Java framework for real-time web applications whose defining idea is that the application code does not
know which transport a client uses. A class annotated with @ManagedService receives text from WebSocket, Server-Sent
Events (SSE), HTTP streaming and long-polling clients through the same @Message method, and a Broadcaster delivers
a message to every subscribed connection in the format its transport needs. The client library, atmosphere.js, opens
the best transport the network allows and falls back when a proxy or firewall refuses the WebSocket upgrade.
This page was checked on 2026-10-24. The baseline is Atmosphere 4.0.76 (Maven Central and the GitHub release of
2026-10-22), which needs JDK 21 and a Servlet 6.0 container (Jakarta EE 10), with atmosphere.js 5.0.52, the
Spring Boot starter built against Spring Boot 4.1.1 (the examples were run on 4.1.2 with Tomcat 11.0.27), the Quarkus
extension built against Quarkus 3.39.5, and Jetty 12.1.12 as the embedded container. The Jakarta WebSocket API that
Atmosphere compiles against is 2.2.0. Every example below was built and run: the Java servers in Docker containers
(maven:3.9.16-eclipse-temurin-21 to build, eclipse-temurin:21.0.12.1_1-jre to run), the clients with Node.js 24.21.0,
ws 8.22.0 and atmosphere.js 5.0.52, and the browser checks in headless Chrome 155.0.8059.85.
How the three applications of this section use it:
-
Chatterbox is the natural fit. Rooms are broadcasters, presence and history exist as a Room API, and the fallback transports keep the chat working behind proxies that refuse WebSocket. This page implements the complete Chatterbox wire protocol of Message Design and Serialization on an Atmosphere endpoint.
-
Huddle can use an Atmosphere endpoint as its signalling channel: offers, answers and ICE candidates are small JSON messages sent to one participant with
Broadcaster.broadcast(message, resource). Atmosphere carries no media, so the audio and video still go through WebRTC (see Signaling and Perfect Negotiation). -
Pixel Arena is a poor fit. Atmosphere delivers text through a pub/sub abstraction and has no tick loop, snapshot or interpolation support. Binary frames exist (
@WebSocketHandlerService), but a game server is better served by the stacks in Choosing a WebSocket Stack. Use Atmosphere for the lobby and chat around the game if the rest of the system is Java.
What Atmosphere is, and where it came from
The core types are small. AtmosphereServlet boots an AtmosphereFramework. Each connection becomes an
AtmosphereResource, a transport-neutral handle with write, suspend, resume and close. A Broadcaster is a
named pub/sub channel that holds a set of resources. An AtmosphereHandler receives requests and state changes, and
@ManagedService is a convenience that generates the handler from annotations. AtmosphereInterceptor classes sit
between the transport and the handler like servlet filters.
The project is old by web standards. Maven Central has atmosphere-runtime 1.0.0 from September 2012 and 2.0.0 from
September 2013, the release that introduced the annotation model used here. The 3.x line followed (3.0.0 in February
2022, the last 3.0.13 in October 2024), and 2.7.15 was still published in October 2024. 4.0.0 was released on
2026-02-20 as a JDK 21 rewrite, and 4.0.76 is the current patch release. The documentation moved from
atmosphere.github.io/docs/ to async-io.live/docs/, and the old address now answers with a permanent redirect.
Atmosphere 4 also repositions itself as a runtime for AI agents (annotations such as @Agent and @AiEndpoint, MCP,
A2A and AG-UI support, a dozen pluggable model runtimes). This page covers only the real-time core, which those features
build on. The agent side belongs with the AI agents section of this site and with the
Atmosphere documentation.
Prerequisites and project setup
Atmosphere 4 requires JDK 21 (the build uses release 21) and the project’s documentation describes a Servlet 6.0 or
newer container: Jetty 12, Tomcat 11, Undertow (through Quarkus) or any other Jakarta EE 10 server. javax.servlet is
gone. Maven 3.9 or later is enough. All Atmosphere artifacts share the group id org.atmosphere.
| Artifact | Version | Purpose |
|---|---|---|
|
4.0.76 |
the core: servlet, annotations, broadcasters, rooms, transports |
|
4.0.76 |
Spring Boot 4 auto-configuration (a separate |
|
4.0.76 |
Quarkus build-time integration |
|
4.0.76 |
clustering broadcasters |
|
4.0.76 |
session stores for reconnecting clients |
|
5.0.52 |
the TypeScript client; the npm tag |
The runtime declares the Jakarta APIs it uses (jakarta.inject, jakarta.websocket, the CDI API) with provided
scope and its JSON support as optional, so an application that runs it on a bare servlet container must add what it
uses itself. The server in this page adds jakarta.inject-api 2.0.1, Jetty, and Jackson 3 (tools.jackson.core,
because the runtime moved from Jackson 2 to Jackson 3.2.3 during the 4.0 line; encoders and filters written against
com.fasterxml.jackson, as in older samples, must be updated to the tools.jackson packages). Maven 3.5 and later do
not resolve LATEST, so always pin a version; a property keeps upgrades to one line.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>example</groupId>
<artifactId>chatterbox-atmosphere</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<atmosphere.version>4.0.76</atmosphere.version>
<jetty.version>12.1.12</jetty.version>
<jackson.version>3.2.3</jackson.version>
</properties>
<dependencies>
<dependency>
<groupId>org.atmosphere</groupId>
<artifactId>atmosphere-runtime</artifactId>
<version>${atmosphere.version}</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.ee10</groupId>
<artifactId>jetty-ee10-servlet</artifactId>
<version>${jetty.version}</version>
</dependency>
<dependency>
<groupId>org.eclipse.jetty.ee10.websocket</groupId>
<artifactId>jetty-ee10-websocket-jakarta-server</artifactId>
<version>${jetty.version}</version>
</dependency>
<dependency>
<groupId>jakarta.inject</groupId>
<artifactId>jakarta.inject-api</artifactId>
<version>2.0.1</version>
</dependency>
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>2.0.17</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.8.1</version>
<executions>
<execution>
<id>copy-deps</id>
<phase>package</phase>
<goals><goal>copy-dependencies</goal></goals>
<configuration><outputDirectory>${project.build.directory}/lib</outputDirectory></configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
The annotation model
@ManagedService turns a plain class into an endpoint. It has seven attributes:
| Attribute | Meaning |
|---|---|
|
URL pattern, with template variables such as |
|
|
|
the Broadcaster class. |
|
interceptors appended to the default set (lifecycle, message-size tracking, heartbeat, suspend tracking). |
|
|
|
cache that replays missed messages to a reconnecting client; |
|
filters that transform or drop a message before delivery. |
Methods carry the lifecycle annotations:
| Annotation | Called when |
|---|---|
|
the connection is suspended and can receive messages. It may return a value that goes only to the connecting client. |
|
a message arrives. A returned value is broadcast to every subscriber of the endpoint’s Broadcaster, unless |
|
the connection closed. |
|
the client sent a heartbeat. |
|
a suspended request resumed, typical for long-polling. |
|
an HTTP request arrives, before the connection is suspended. |
Two details cost time in practice. First, by default Atmosphere creates a new instance of the class for every
connection; @Singleton (the Atmosphere annotation in org.atmosphere.config.service, not jakarta.inject.Singleton)
makes one instance serve all of them, which is what an endpoint that keeps shared state needs, and then the state must
be thread-safe. Second, @Message accepts either one parameter (the decoded message) or two (the AtmosphereResource
first, then the message). The two-parameter form, visible in the 4.0.76 sources of ManagedAtmosphereHandler, is the
way to know which connection sent a frame in a singleton; a third parameter is ignored with a warning.
Injection uses jakarta.inject.Inject and Named: @Inject @Named("/chat") Broadcaster gives the endpoint’s own
broadcaster, @Inject BroadcasterFactory the registry, and AtmosphereResource and AtmosphereResourceEvent the
current connection and event. Encoders and decoders (Encoder<T, String>, Decoder<String, T>) turn the wire text
into domain objects and back, and are injectable too.
Broadcasters
A Broadcaster is created per path by @ManagedService, or on demand from the BroadcasterFactory:
Broadcaster room = factory.lookup("/rooms/general", true); // find or create by id
room.addAtmosphereResource(resource); // subscribe a connection
room.broadcast(message); // everyone subscribed
room.broadcast(message, resource); // one connection
room.broadcast(message, someSetOfResources); // a subset
room.removeAtmosphereResource(resource); // unsubscribe
Broadcasts are asynchronous and return a Future<Object>. In Atmosphere 4 delivery runs on virtual threads and
DefaultBroadcaster uses ReentrantLock rather than synchronized, so a broadcast to thousands of subscribers does
not pin carrier threads. Order is preserved: broadcast("A") then broadcast("B") reach every client as A then B,
unless the init parameter org.atmosphere.cpr.Broadcaster.supportOutOfOrderBroadcast is set to true. The factory is
thread-safe and can be called from a scheduled task, a Kafka consumer or a REST controller, which is how server-side
code pushes to browsers. factory.findBroadcaster(id) returns an Optional and is preferred over lookup.
Three companions matter:
-
BroadcasterCachekeeps messages for a client that briefly disconnected and replays them when the same resource uuid returns. It is a per-node, in-memory buffer, not a shared log. -
BroadcastFilter(andPerRequestBroadcastFilterfor per-subscriber decisions) transforms or drops a message. On the managed path the encoder runs first, so a filter receives aRawMessageor the encodedString, not your domain type. A filter that only testsinstanceof ChatMessagecompiles, registers and never fires. -
RawMessagewraps content that is already encoded. A plain object broadcast through a Broadcaster is, in 4.0.76, passed through the endpoint’s@Messagepipeline for each subscriber (the source logs a recursion warning for it), so broadcast pre-encoded JSON asnew RawMessage(json). The Chatterbox server below does so for every fan-out.
A Broadcaster also has a scope (REQUEST, APPLICATION by default, VM) and scheduling helpers
(delayBroadcast, scheduleFixedBroadcast).
Transports and the fallback chain
The same endpoint serves several transports. Which one a connection uses is decided by the client:
| Transport | How it works | Direction |
|---|---|---|
WebSocket |
one full-duplex TCP connection after an HTTP upgrade |
both |
SSE |
a long-lived |
server to client |
Streaming |
the server keeps writing into an open HTTP response |
server to client |
Long-polling |
the client asks, the server holds the request until data exists, answers, and the client asks again |
repeated request and response |
WebTransport over HTTP/3 |
bidirectional QUIC streams through a Netty sidecar on a UDP port; optional and off by default |
both |
gRPC |
HTTP/2 streams with Protocol Buffers ( |
both |
atmosphere.js implements the client side. It is told a primary transport and one fallbackTransport, and the
fallback is single-level and client-driven: when the primary fails, the client fires transportFailure once and
continues with the fallback.
The client attaches protocol parameters to every request, as headers or query parameters: X-Atmosphere-Transport
(websocket, sse, long-polling, streaming, webtransport or close), X-Atmosphere-tracking-id (zero on the first
request; the server assigns a uuid), X-Atmosphere-Framework (the client version) and, when the client enables the
protocol handshake, X-atmo-protocol=true. With TrackMessageSizeInterceptor, which is part of the managed-service
defaults, each message written to an HTTP transport is prefixed with its length so that several messages in one chunk
can be split again. Capturing a real SSE connection shows all of it:
GET /chat/lobby?X-Atmosphere-Transport=sse&X-Atmosphere-tracking-id=0&X-atmo-protocol=true&X-Atmosphere-TrackMessageSize=true
HTTP/1.1 200 OK
X-Atmosphere-first-request: true
X-Atmosphere-tracking-id: 459df088-e5d8-48b9-91ae-384a710f0b55
Content-Type: text/event-stream;charset=utf-8
Transfer-Encoding: chunked
(a block of padding spaces)
data:41|459df088-e5d8-48b9-91ae-384a710f0b55|0|X|
data:71|{"author":"curl","text":"hi sse","at":"2026-10-24T19:59:00.450046465Z"}
The first event is the handshake (length, tracking id, heartbeat interval, padding character); the second is the message, preceded by its length. The padding block is there for old browsers that buffer the start of a stream.
Source: Atmosphere tutorial: Transports
WebTransport is optional: the project’s reference describes a Netty HTTP/3 sidecar enabled with
atmosphere.web-transport.enabled=true in Spring Boot, and a client that asks for transport: 'webtransport' with a
WebSocket fallback. The examples on this page do not enable it; the standard itself is covered in
WebTransport and Media over QUIC.
The fallback transports are the reason Atmosphere needs sticky sessions behind a load balancer: a long-polling or streaming client talks to the node that holds its suspended request, whereas a WebSocket connection is pinned by the connection itself. See Proxies, Load Balancers and Deployment. For the HTTP side of these techniques in general, Server-Sent Events and Long Polling has the protocol detail.
Chatterbox on Atmosphere
The first complete example is the Chatterbox server. It implements protocol v1 of
Message Design and Serialization: the envelope
with v, type, id, room, ts and payload, the hello handshake with a one-time ticket, join and leave,
message, edit, react, typing, receipt, presence and history, the ack and error answers with the
protocol’s error codes, per-room seq numbers, idempotent client ids, the 20 per second token bucket, the close codes
4401, 4403, 4408, 4426 and 4429, and the Origin allow-list. It is a single @ManagedService on /ws: one socket per
user and many rooms over it, the way the other servers in this section work. Each room is a Broadcaster named
/rooms/<room>, so fan-out is broadcast and the typing, presence and receipt envelopes that must skip the sender
use broadcast(message, subset).
Design
-
State. A
Connper connection (keyed by the resource uuid), aRoomper room (sequence counter, event log for replay, message index for history, reaction set, member connections), and a per-user cache of the last 1000 acks for idempotency. All of it lives in memory in one JVM; the clustering section explains what changes with several nodes. -
Threading. Container threads call the endpoint concurrently, so the shared maps are concurrent and each room’s sequence assignment, log append and broadcast happen under that room’s monitor. That makes broadcast order equal to
seqorder. -
Timers. A single scheduled executor closes a connection that did not say hello within 5 seconds (4408), pings every connection with
WebSocket.sendPingand closes one that was silent for twice the heartbeat (4408), and broadcastsofflinepresence after the grace period. -
Replies. Answers go to the sending connection with
AtmosphereResource.write(String), and fan-out goes through the room Broadcaster withRawMessage.
Server code
Main starts Jetty with the Jakarta WebSocket container and registers AtmosphereServlet. The order matters: the
WebSocket container must be configured before the Atmosphere servlet initialises, or the JSR 356 endpoints cannot be
added. The init parameters are the production-relevant part of this class:
-
useWebSocket=trueandmaxTextMessageSize=16384enable the transport and cap a frame at the protocol’s limit. -
requireSameOrigin=falseswitches off Atmosphere’s built-in same-origin check, replaced here by an explicit allow-list (the security section explains the default). -
HeartbeatInterceptor.heartbeatFrequencyInSeconds=0turns off Atmosphere’s own heartbeat. With the default of 60 seconds the server writes a padding text frame (the characterX) to every WebSocket client, which a JSON-only protocol cannot parse. This was reproduced by setting the interval to 2 seconds and reading the frames. -
allow1005disconnectEvent=truemakes a close frame without a status code fire@Disconnect(see the pitfalls). -
cors.allowedOriginsgives the CORS interceptor the same allow-list; without it the interceptor logs that it is "echoing all Origin headers".
package example.chatterbox;
import org.atmosphere.cpr.ApplicationConfig;
import org.atmosphere.cpr.AtmosphereServlet;
import org.eclipse.jetty.ee10.servlet.ServletContextHandler;
import org.eclipse.jetty.ee10.servlet.ServletHolder;
import org.eclipse.jetty.ee10.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
public final class Main {
public static void main(String[] args) throws Exception {
int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8080"));
Server server = new Server();
ServerConnector connector = new ServerConnector(server);
connector.setPort(port);
server.addConnector(connector);
ServletContextHandler context = new ServletContextHandler("/");
// The Jakarta WebSocket container must exist before AtmosphereServlet initialises.
JakartaWebSocketServletContainerInitializer.configure(context,
(servletContext, container) -> container.addEndpoint(PlainEcho.class));
ServletHolder atmosphere = new ServletHolder(AtmosphereServlet.class);
atmosphere.setInitParameter(ApplicationConfig.ANNOTATION_PACKAGE, "example.chatterbox");
atmosphere.setInitParameter(ApplicationConfig.WEBSOCKET_SUPPORT, "true");
atmosphere.setInitParameter(ApplicationConfig.WEBSOCKET_MAXTEXTSIZE, "16384");
// Replace Atmosphere's built-in same-origin check with an explicit allow-list.
atmosphere.setInitParameter(ApplicationConfig.WEBSOCKET_REQUIRE_SAME_ORIGIN, "false");
String origins = System.getenv().getOrDefault("ALLOWED_ORIGINS", "https://chat.example.test");
atmosphere.setInitParameter("chatterbox.allowedOrigins", origins);
atmosphere.setInitParameter(ApplicationConfig.CORS_ALLOWED_ORIGINS, origins);
atmosphere.setInitParameter(ApplicationConfig.ATMOSPHERE_INTERCEPTORS, OriginInterceptor.class.getName());
// Treat a close frame without status code (1005) as a disconnect so @Disconnect fires.
atmosphere.setInitParameter(ApplicationConfig.ALLOW_WEBSOCKET_STATUS_CODE_1005_AS_DISCONNECT, "true");
// Atmosphere's own heartbeat writes a padding frame that is not Chatterbox JSON: switch it off.
atmosphere.setInitParameter(ApplicationConfig.HEARTBEAT_INTERVAL_IN_SECONDS, "0");
atmosphere.setInitOrder(1);
atmosphere.setAsyncSupported(true);
context.addServlet(atmosphere, "/*");
ServletHolder tickets = new ServletHolder(new Tickets());
tickets.setInitParameter("allowedOrigins", origins);
context.addServlet(tickets, "/tickets");
server.setHandler(context);
server.start();
server.join();
}
}
The ticket servlet stands in for your login: a real application issues the ticket from an authenticated HTTP request
(cookie or session) and the browser then redeems it in hello, because the browser WebSocket API cannot set an
Authorization header. A ticket is one-time and expires after 30 seconds. See
Authentication and Authorization for the trade-offs of
cookies, query-string tickets and first-message tokens.
package example.chatterbox;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* One-time tickets that the first hello frame redeems. A real application issues
* a ticket from an authenticated HTTP endpoint; this demo trusts the query string.
*/
public final class Tickets extends HttpServlet {
record Ticket(String userId, String name, long expiresAt) {
}
private static final long TTL_MS = 30_000;
private static final Map<String, Ticket> TICKETS = new ConcurrentHashMap<>();
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
String user = req.getParameter("user");
if (user == null || !user.matches("[a-z0-9]{1,32}")) {
resp.sendError(400, "user must match [a-z0-9]{1,32}");
return;
}
String origin = req.getHeader("Origin");
if (origin != null && getInitParameter("allowedOrigins") != null
&& List.of(getInitParameter("allowedOrigins").split(",")).contains(origin)) {
resp.setHeader("Access-Control-Allow-Origin", origin); // the page may live on another origin
resp.setHeader("Vary", "Origin");
}
String ticket = UUID.randomUUID().toString();
TICKETS.put(ticket, new Ticket(user, req.getParameter("name") == null ? user : req.getParameter("name"),
System.currentTimeMillis() + TTL_MS));
resp.setContentType("application/json");
resp.getWriter().write("{\"ticket\":\"" + ticket + "\"}");
}
/** Redeems a ticket once; returns null when it is unknown, used or expired. */
static Ticket redeem(String ticket) {
Ticket t = ticket == null ? null : TICKETS.remove(ticket);
return t != null && t.expiresAt() > System.currentTimeMillis() ? t : null;
}
}
The Origin allow-list is an AtmosphereInterceptor with FIRST_BEFORE_DEFAULT priority, so it runs before everything
else. A request without an Origin header comes from a non-browser client and passes, as the protocol requires;
those clients rely on the ticket. On a WebSocket the upgrade has already completed when interceptors run, so the
refusal is a close frame with the application code 4403, not an HTTP 403.
package example.chatterbox;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import org.atmosphere.cpr.Action;
import org.atmosphere.cpr.AtmosphereConfig;
import org.atmosphere.cpr.AtmosphereInterceptorAdapter;
import org.atmosphere.cpr.AtmosphereResource;
import org.atmosphere.websocket.WebSocket;
/** Allow-list for the Origin header; requests without Origin (non-browsers) pass and rely on the ticket. */
public final class OriginInterceptor extends AtmosphereInterceptorAdapter {
private Set<String> allowed = Set.of();
@Override
public void configure(AtmosphereConfig config) {
String list = config.getInitParameter("chatterbox.allowedOrigins", "");
allowed = Arrays.stream(list.split(",")).map(String::trim).filter(s -> !s.isEmpty())
.collect(Collectors.toSet());
}
@Override
public PRIORITY priority() {
return PRIORITY.FIRST_BEFORE_DEFAULT;
}
@Override
public Action inspect(AtmosphereResource r) {
String origin = r.getRequest().getHeader("Origin");
if (origin == null || allowed.contains(origin)) {
return Action.CONTINUE;
}
if (r.getResponse().getAsyncIOWriter() instanceof WebSocket ws) {
ws.close(4403, "origin not allowed");
}
return Action.CANCELLED;
}
}
package example.chatterbox;
import java.security.SecureRandom;
/** Server-originated envelope ids: "s_" followed by a 26-character ULID. */
final class Ids {
private static final char[] ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".toCharArray();
private static final SecureRandom RANDOM = new SecureRandom();
private Ids() {
}
static String serverId() {
return "s_" + ulid();
}
static String ulid() {
char[] out = new char[26];
long time = System.currentTimeMillis();
for (int i = 9; i >= 0; i--) {
out[i] = ALPHABET[(int) (time & 31)];
time >>>= 5;
}
for (int i = 10; i < 26; i++) {
out[i] = ALPHABET[RANDOM.nextInt(32)];
}
return new String(out);
}
}
Source: ULID specification
The endpoint is the heart of the example. It is shown in three consecutive parts of one file. Part 1 holds the state,
the lifecycle callbacks and the dispatcher onFrame:
package example.chatterbox;
import jakarta.inject.Inject;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.atmosphere.config.service.Disconnect;
import org.atmosphere.config.service.ManagedService;
import org.atmosphere.config.service.Message;
import org.atmosphere.config.service.Ready;
import org.atmosphere.config.service.Singleton;
import org.atmosphere.cpr.AtmosphereResource;
import org.atmosphere.cpr.AtmosphereResourceEvent;
import org.atmosphere.cpr.Broadcaster;
import org.atmosphere.cpr.BroadcasterFactory;
import org.atmosphere.cpr.RawMessage;
import org.atmosphere.websocket.WebSocket;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
/** Chatterbox wire protocol v1 on one Atmosphere endpoint: one socket, many rooms. */
@Singleton
@ManagedService(path = "/ws")
public class ChatterboxEndpoint {
private static final Logger log = LoggerFactory.getLogger(ChatterboxEndpoint.class);
private static final ObjectMapper JSON = JsonMapper.builder().build();
private static final int MAX_FRAME_BYTES = 16384;
private static final int HEARTBEAT_MS = Integer.getInteger("chatterbox.heartbeatMs", 20_000);
private static final long GRACE_MS = Long.getLong("chatterbox.graceMs", 15_000);
private static final int ACK_CACHE = 1000;
/** Per-connection state, keyed by the AtmosphereResource uuid. */
private static final class Conn {
final AtmosphereResource resource;
String userId;
String name;
String status = "online";
volatile boolean helloDone;
volatile long lastSeen = System.currentTimeMillis();
double tokens = 40;
long refilled = System.nanoTime();
int rateErrors;
long firstRateError;
final Set<String> rooms = ConcurrentHashMap.newKeySet();
Conn(AtmosphereResource resource) {
this.resource = resource;
}
}
private static final class Room {
final String name;
final Broadcaster broadcaster;
long seq;
final List<ObjectNode> events = new ArrayList<>();
final Map<String, ObjectNode> messages = new LinkedHashMap<>();
final Set<String> reactions = new HashSet<>();
final Set<Conn> conns = ConcurrentHashMap.newKeySet();
Room(String name, Broadcaster broadcaster) {
this.name = name;
this.broadcaster = broadcaster;
}
}
private final Map<String, Conn> conns = new ConcurrentHashMap<>();
private final Map<String, Room> rooms = new ConcurrentHashMap<>();
private final Map<String, Map<String, ObjectNode>> acked = new ConcurrentHashMap<>();
private final ScheduledExecutorService timers = Executors.newSingleThreadScheduledExecutor();
@Inject
private BroadcasterFactory factory;
public ChatterboxEndpoint() {
timers.scheduleAtFixedRate(this::sweep, HEARTBEAT_MS, HEARTBEAT_MS, TimeUnit.MILLISECONDS);
}
@Ready
public void onReady(AtmosphereResource r) {
Conn c = new Conn(r);
conns.put(r.uuid(), c);
timers.schedule(() -> {
if (!c.helloDone && conns.containsKey(r.uuid())) {
close(c, 4408, "hello timeout");
}
}, 5, TimeUnit.SECONDS);
}
@Disconnect
public void onDisconnect(AtmosphereResourceEvent event) {
log.debug("disconnect {} cancelled={} closedByClient={}", event.getResource().uuid(), event.isCancelled(), event.isClosedByClient());
Conn c = conns.remove(event.getResource().uuid());
if (c == null) {
return;
}
c.rooms.forEach(name -> rooms.get(name).conns.remove(c));
if (c.userId != null) {
timers.schedule(() -> goOfflineIfGone(c), GRACE_MS, TimeUnit.MILLISECONDS);
}
}
@Message
public void onFrame(AtmosphereResource r, String frame) {
Conn c = conns.get(r.uuid());
if (c == null) {
return;
}
c.lastSeen = System.currentTimeMillis();
if (frame.getBytes(StandardCharsets.UTF_8).length > MAX_FRAME_BYTES) {
close(c, 4413, "frame too large");
return;
}
JsonNode in;
try {
in = JSON.readTree(frame);
} catch (JacksonException e) {
error(c, null, null, "bad_request", "frame is not JSON", false);
return;
}
String id = text(in, "id");
String type = text(in, "type");
String roomName = in.path("room").isString() ? in.path("room").stringValue("") : null;
JsonNode payload = in.path("payload");
if (!in.path("v").isInt() || !payload.isObject() || type == null || id == null) {
error(c, id, roomName, "bad_request", "v, type, id and payload are required", false);
return;
}
if (in.path("v").intValue() != 1) {
error(c, id, roomName, "unsupported_version", "only v1 is supported", false);
close(c, 4426, "unsupported version");
return;
}
if (!takeToken(c)) {
error(c, id, roomName, "rate_limited", "slow down", true);
return;
}
if (!c.helloDone && !type.equals("hello")) {
error(c, id, roomName, "unauthorized", "send hello first", false);
close(c, 4401, "unauthorized");
return;
}
Map<String, ObjectNode> cache = c.userId == null ? null
: acked.computeIfAbsent(c.userId, k -> new LinkedHashMap<>());
if (cache != null) {
ObjectNode previous;
synchronized (cache) {
previous = cache.get(id);
}
if (previous != null) {
ObjectNode copy = previous.deepCopy();
((ObjectNode) copy.get("payload")).put("dup", true);
write(c, copy);
return;
}
}
switch (type) {
case "hello" -> hello(c, id, payload);
case "join" -> join(c, id, roomName, payload);
case "leave" -> leave(c, id, roomName);
case "message" -> message(c, id, roomName, payload);
case "edit" -> edit(c, id, roomName, payload);
case "react" -> react(c, id, roomName, payload);
case "typing" -> relay(c, "typing", roomName, JSON.createObjectNode()
.put("userId", c.userId).put("state", payload.path("state").asString("stop")));
case "receipt" -> receipt(c, id, roomName, payload);
case "presence" -> presence(c, id, payload);
case "history" -> history(c, id, roomName, payload);
default -> error(c, id, roomName, "bad_request", "unknown type " + type, false);
}
}
Part 2 holds one method per envelope type. join adds the connection to the room Broadcaster, acknowledges with the
member list and lastSeq, replays missed events when since is set, and announces presence to the others. message
checks membership, text length and replyTo, assigns seq and broadcasts under the room lock; ack also stores the
answer in the idempotency cache, which is what turns a retry with the same id into dup: true.
// ---- handlers ----------------------------------------------------------------------------
private void hello(Conn c, String id, JsonNode p) {
if (c.helloDone) {
error(c, id, null, "conflict", "already said hello", false);
return;
}
Tickets.Ticket t = Tickets.redeem(p.path("token").asString(null));
if (t == null) {
error(c, id, null, "unauthorized", "invalid or expired ticket", false);
close(c, 4401, "unauthorized");
return;
}
c.userId = t.userId();
c.name = t.name();
c.helloDone = true;
ObjectNode reply = JSON.createObjectNode().put("ref", id).put("userId", c.userId).put("name", c.name)
.put("sessionId", Ids.ulid()).put("heartbeatMs", HEARTBEAT_MS).put("maxFrameBytes", MAX_FRAME_BYTES)
.put("resumed", false);
reply.putArray("rooms");
write(c, envelope("hello", null, reply));
}
private void join(Conn c, String id, String roomName, JsonNode p) {
if (!validRoom(c, id, roomName)) {
return;
}
Room room = rooms.computeIfAbsent(roomName, n -> new Room(n, factory.lookup("/rooms/" + n, true)));
boolean first = c.rooms.add(roomName);
room.conns.add(c);
room.broadcaster.addAtmosphereResource(c.resource);
ObjectNode result = JSON.createObjectNode();
ArrayNode members = result.putArray("members");
Set<String> seen = new HashSet<>();
for (Conn m : room.conns) {
if (seen.add(m.userId)) {
members.addObject().put("userId", m.userId).put("name", m.name).put("status", m.status);
}
}
long lastSeq;
synchronized (room) {
lastSeq = room.seq;
result.put("lastSeq", lastSeq);
ack(c, id, roomName, null, false, result);
if (p.path("since").isIntegralNumber()) {
long since = p.path("since").longValue();
room.events.stream().filter(e -> e.path("payload").path("seq").longValue() > since)
.forEach(e -> write(c, e));
}
}
if (first) {
relay(c, "presence", roomName, JSON.createObjectNode().put("userId", c.userId).put("name", c.name)
.put("status", c.status));
}
}
private void leave(Conn c, String id, String roomName) {
Room room = roomName == null ? null : rooms.get(roomName);
if (room == null || !c.rooms.remove(roomName)) {
error(c, id, roomName, "not_found", "not a member of " + roomName, false);
return;
}
room.conns.remove(c);
room.broadcaster.removeAtmosphereResource(c.resource);
ack(c, id, roomName, null, false, JSON.createObjectNode());
relay(c, "presence", roomName, JSON.createObjectNode().put("userId", c.userId).put("name", c.name)
.put("status", "offline"));
}
private void message(Conn c, String id, String roomName, JsonNode p) {
Room room = memberRoom(c, id, roomName);
if (room == null) {
return;
}
String text = p.path("text").asString(null);
if (text == null || text.isEmpty() || text.length() > 4000) {
error(c, id, roomName, "bad_request", "text must be 1..4000 characters", false);
return;
}
if (p.path("attachments").isArray() && p.path("attachments").size() > 5) {
error(c, id, roomName, "bad_request", "at most 5 attachments", false);
return;
}
synchronized (room) {
String replyTo = p.path("replyTo").isString() ? p.path("replyTo").stringValue("") : null;
if (replyTo != null && !room.messages.containsKey(replyTo)) {
error(c, id, roomName, "not_found", "replyTo is not a message of this room", false);
return;
}
long seq = ++room.seq;
ObjectNode body = JSON.createObjectNode();
body.putObject("from").put("userId", c.userId).put("name", c.name);
body.put("text", text);
body.set("replyTo", replyTo == null ? JSON.nullNode() : JSON.valueToTree(replyTo));
body.set("attachments", p.path("attachments").isArray() ? p.path("attachments") : JSON.createArrayNode());
body.put("seq", seq).put("edited", false);
ObjectNode env = envelope("message", roomName, body);
env.put("id", id);
room.events.add(env);
room.messages.put(id, env);
room.broadcaster.broadcast(new RawMessage(JSON.writeValueAsString(env)));
ack(c, id, roomName, seq, false, JSON.createObjectNode());
}
}
private void edit(Conn c, String id, String roomName, JsonNode p) {
Room room = memberRoom(c, id, roomName);
if (room == null) {
return;
}
synchronized (room) {
ObjectNode target = room.messages.get(p.path("target").asString(""));
if (target == null) {
error(c, id, roomName, "not_found", "unknown target", false);
return;
}
if (!target.path("payload").path("from").path("userId").asString("").equals(c.userId)) {
error(c, id, roomName, "forbidden", "only the author may edit", false);
return;
}
String text = p.path("text").asString("");
if (text.isEmpty() || text.length() > 4000) {
error(c, id, roomName, "bad_request", "text must be 1..4000 characters", false);
return;
}
long seq = ++room.seq;
((ObjectNode) target.get("payload")).put("text", text).put("edited", true);
ObjectNode body = JSON.createObjectNode().put("target", p.path("target").asString()).put("text", text)
.put("seq", seq).put("editedAt", System.currentTimeMillis());
ObjectNode env = envelope("edit", roomName, body);
env.put("id", id);
room.events.add(env);
room.broadcaster.broadcast(new RawMessage(JSON.writeValueAsString(env)));
ack(c, id, roomName, seq, false, JSON.createObjectNode());
}
}
private void react(Conn c, String id, String roomName, JsonNode p) {
Room room = memberRoom(c, id, roomName);
if (room == null) {
return;
}
synchronized (room) {
String target = p.path("target").asString("");
if (!room.messages.containsKey(target)) {
error(c, id, roomName, "not_found", "unknown target", false);
return;
}
String emoji = p.path("emoji").asString("");
String op = p.path("op").asString("add");
String key = target + "|" + c.userId + "|" + emoji;
if (emoji.isEmpty() || !(op.equals("add") || op.equals("remove"))) {
error(c, id, roomName, "bad_request", "emoji and op (add or remove) are required", false);
return;
}
if (op.equals("add") ? !room.reactions.add(key) : !room.reactions.remove(key)) {
error(c, id, roomName, op.equals("add") ? "conflict" : "not_found",
op.equals("add") ? "already reacted" : "no such reaction", false);
return;
}
long seq = ++room.seq;
ObjectNode body = JSON.createObjectNode().put("target", target).put("emoji", emoji).put("op", op);
body.putObject("from").put("userId", c.userId);
body.put("seq", seq);
ObjectNode env = envelope("react", roomName, body);
env.put("id", id);
room.events.add(env);
room.broadcaster.broadcast(new RawMessage(JSON.writeValueAsString(env)));
ack(c, id, roomName, seq, false, JSON.createObjectNode());
}
}
private void receipt(Conn c, String id, String roomName, JsonNode p) {
Room room = roomName == null ? null : rooms.get(roomName);
if (room == null || !c.rooms.contains(roomName)) {
return;
}
long upTo = p.path("upTo").longValue();
if (upTo > room.seq) {
error(c, id, roomName, "bad_request", "upTo is beyond the last seq", false);
return;
}
relay(c, "receipt", roomName, JSON.createObjectNode().put("userId", c.userId)
.put("kind", p.path("kind").asString("read")).put("upTo", upTo));
}
private void presence(Conn c, String id, JsonNode p) {
String status = p.path("status").asString("online");
if (!status.equals("online") && !status.equals("away")) {
error(c, id, null, "bad_request", "status must be online or away", false);
return;
}
boolean changed = !status.equals(c.status);
c.status = status;
ack(c, id, null, null, false, JSON.createObjectNode());
if (changed) {
for (String r : c.rooms) {
relay(c, "presence", r, JSON.createObjectNode().put("userId", c.userId).put("name", c.name)
.put("status", status));
}
}
}
private void history(Conn c, String id, String roomName, JsonNode p) {
Room room = memberRoom(c, id, roomName);
if (room == null) {
return;
}
int limit = Math.max(1, Math.min(100, p.path("limit").asInt(50)));
long before = p.path("before").isIntegralNumber() ? p.path("before").longValue() : Long.MAX_VALUE;
ObjectNode result = JSON.createObjectNode();
ArrayNode page = result.putArray("messages");
synchronized (room) {
List<ObjectNode> older = room.messages.values().stream()
.filter(m -> m.path("payload").path("seq").longValue() < before).toList();
int from = Math.max(0, older.size() - limit);
older.subList(from, older.size()).forEach(page::add);
boolean hasMore = from > 0;
result.put("hasMore", hasMore);
if (hasMore) {
result.put("next", older.get(from).path("payload").path("seq").longValue());
} else {
result.putNull("next");
}
}
ack(c, id, roomName, null, false, result);
}
Part 3 is the plumbing: validation helpers, the subset fan-out relay, the offline grace timer, the heartbeat sweep,
the token bucket, envelope construction, ack, error, write and close. The close is delayed by 100 ms on
purpose: on Tomcat, closing in the same instant as the preceding error frame sometimes reached the client as 1006
instead of the application code, and the delay removed the race.
// ---- plumbing ----------------------------------------------------------------------------
private boolean validRoom(Conn c, String id, String roomName) {
if (roomName == null || !roomName.matches("[a-z0-9-]{1,64}")) {
error(c, id, roomName, "bad_request", "room must match [a-z0-9-]{1,64}", false);
return false;
}
if (roomName.startsWith("dm-") && !List.of(roomName.substring(3).split("-")).contains(c.userId)) {
error(c, id, roomName, "forbidden", "not a party of this DM", false);
return false;
}
return true;
}
private Room memberRoom(Conn c, String id, String roomName) {
Room room = roomName == null ? null : rooms.get(roomName);
if (room == null || !c.rooms.contains(roomName)) {
error(c, id, roomName, "forbidden", "join the room first", false);
return null;
}
return room;
}
/** Sends typing, receipt and presence envelopes to the other members of a room. */
private void relay(Conn from, String type, String roomName, ObjectNode body) {
Room room = rooms.get(roomName);
if (room == null) {
return;
}
Set<AtmosphereResource> others = new HashSet<>();
room.conns.stream().filter(m -> m != from).forEach(m -> others.add(m.resource));
if (!others.isEmpty()) {
room.broadcaster.broadcast(new RawMessage(JSON.writeValueAsString(envelope(type, roomName, body))), others);
}
}
private void goOfflineIfGone(Conn gone) {
boolean stillHere = conns.values().stream().anyMatch(m -> gone.userId.equals(m.userId));
if (!stillHere) {
gone.rooms.forEach(r -> {
Room room = rooms.get(r);
if (room != null) {
ObjectNode body = JSON.createObjectNode().put("userId", gone.userId).put("name", gone.name)
.put("status", "offline");
room.broadcaster.broadcast(new RawMessage(JSON.writeValueAsString(envelope("presence", r, body))));
}
});
}
}
private void sweep() {
long now = System.currentTimeMillis();
for (Conn c : conns.values()) {
if (now - c.lastSeen > 2L * HEARTBEAT_MS) {
close(c, 4408, "heartbeat timeout");
} else if (c.resource.getResponse().getAsyncIOWriter() instanceof WebSocket ws) {
try {
ws.sendPing(new byte[0]);
} catch (RuntimeException e) {
log.debug("ping failed", e);
}
}
}
}
private boolean takeToken(Conn c) {
long now = System.nanoTime();
c.tokens = Math.min(40, c.tokens + (now - c.refilled) / 1e9 * 20);
c.refilled = now;
if (c.tokens >= 1) {
c.tokens -= 1;
return true;
}
long ms = System.currentTimeMillis();
if (ms - c.firstRateError > 10_000) {
c.firstRateError = ms;
c.rateErrors = 0;
}
if (++c.rateErrors >= 10) {
close(c, 4429, "rate limited");
}
return false;
}
private static String text(JsonNode n, String field) {
return n.path(field).isString() ? n.path(field).stringValue("") : null;
}
private static ObjectNode envelope(String type, String room, ObjectNode payload) {
ObjectNode env = JSON.createObjectNode().put("v", 1).put("type", type).put("id", Ids.serverId());
if (room == null) {
env.putNull("room");
} else {
env.put("room", room);
}
env.put("ts", System.currentTimeMillis());
env.set("payload", payload);
return env;
}
private void ack(Conn c, String ref, String room, Long seq, boolean dup, ObjectNode result) {
ObjectNode body = JSON.createObjectNode().put("ref", ref).put("ok", true);
if (seq == null) {
body.putNull("seq");
} else {
body.put("seq", seq.longValue());
}
body.put("dup", dup);
body.set("result", result);
ObjectNode env = envelope("ack", room, body);
Map<String, ObjectNode> cache = acked.computeIfAbsent(c.userId, k -> new LinkedHashMap<>());
synchronized (cache) {
cache.put(ref, env.deepCopy());
if (cache.size() > ACK_CACHE) {
cache.remove(cache.keySet().iterator().next());
}
}
write(c, env);
}
private void error(Conn c, String ref, String room, String code, String message, boolean retryable) {
ObjectNode body = JSON.createObjectNode();
if (ref == null) {
body.putNull("ref");
} else {
body.put("ref", ref);
}
body.put("code", code).put("message", message).put("retryable", retryable);
write(c, envelope("error", room, body));
}
private void write(Conn c, JsonNode env) {
try {
c.resource.write(JSON.writeValueAsString(env));
} catch (RuntimeException e) {
log.debug("write to {} failed: {}", c.resource.uuid(), e.getMessage());
}
}
/** Closes with an application code after a short delay, so the error frame written just before drains first. */
private void close(Conn c, int code, String reason) {
timers.schedule(() -> {
if (c.resource.getResponse().getAsyncIOWriter() instanceof WebSocket ws) {
ws.close(code, reason);
}
}, 100, TimeUnit.MILLISECONDS);
}
}
Source for the three parts: Atmosphere tutorial: @ManagedService Deep Dive and Broadcaster and Pub/Sub
What this server leaves out
The server is deliberately single-node and in-memory. Compared with the full protocol it differs in these points:
-
Resume.
helloalways answersresumed: false; a reconnecting client rejoins its rooms withsince, which the server honours by replaying the events with a greaterseq. The 120 second session memory of the protocol is not kept. -
Idempotency window. The ack cache holds the last 1000 ids per user with no 24 hour expiry.
-
Binary frames. A binary frame reaches
@Messageas garbled text and is answered withbad_request; the protocol’s close 4400 is not sent. -
Oversize frames. Jetty enforces
maxTextMessageSizeitself and closes with 1009 instead of the protocol’s 4413. The endpoint’s own 4413 check is a second line of defence. -
No subprotocol. See the browser client below.
-
Persistence. History lives in the room object; a restart empties it. Attachments are echoed, not validated or stored.
Build and run
Main also registers the plain Jakarta endpoint PlainEcho that the comparison section explains, so the project needs
that class too.
docker run --rm -v "$PWD":/app -v "$HOME/.m2":/root/.m2 -w /app maven:3.9.16-eclipse-temurin-21 \
mvn -q -B package -DskipTests
docker run --rm -p 8080:8080 -v "$PWD":/app -w /app \
-e ALLOWED_ORIGINS=https://chat.example.test \
eclipse-temurin:21.0.12.1_1-jre java -cp 'target/classes:target/lib/*' example.chatterbox.Main
Driving it from a test client
The client below uses the global fetch and crypto of Node.js 22 or later and the ws package (the Node.js
global WebSocket cannot set the Origin header). It gets a ticket, says hello, and exercises join, broadcast, ack,
a duplicate id, two protocol errors and history. request resolves with the ack or the error whose ref is the id it
sent.
// demo.mjs - drives the Chatterbox server: join, broadcast, ack, duplicate id, error.
// Needs Node.js 22 or later (global fetch, crypto) and the ws package: npm install ws@8.22.0
import WebSocket from 'ws';
const BASE = process.env.CHATTERBOX_URL ?? 'http://localhost:8080';
const ORIGIN = 'https://chat.example.test';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const newId = () => crypto.randomUUID().replaceAll('-', '').slice(0, 26).toUpperCase();
async function connect(user) {
const { ticket } = await (await fetch(`${BASE}/tickets?user=${user}`, { method: 'POST' })).json();
const ws = new WebSocket(BASE.replace('http', 'ws') + '/ws', { origin: ORIGIN });
const inbox = [];
ws.on('message', (data) => inbox.push(JSON.parse(data.toString())));
await new Promise((resolve) => ws.on('open', resolve));
const client = {
ws,
send(type, room, payload, id = newId()) {
ws.send(JSON.stringify({ v: 1, type, id, room, ts: 0, payload }));
return id;
},
async next(predicate) {
for (let i = 0; i < 150; i++) {
const at = inbox.findIndex(predicate);
if (at >= 0) return inbox.splice(at, 1)[0];
await sleep(20);
}
throw new Error(`${user}: timed out waiting, inbox=${JSON.stringify(inbox)}`);
},
// One request, one answer: the ack or the error whose ref is the id we sent.
async request(type, room, payload, id = newId()) {
client.send(type, room, payload, id);
return client.next((e) => (e.type === 'ack' || e.type === 'error') && e.payload.ref === id);
},
};
client.send('hello', null, { token: ticket, client: 'demo/1.0' });
await client.next((e) => e.type === 'hello');
return client;
}
const line = (who, text) => console.log(`${who.padEnd(6)} ${text}`);
const alice = await connect('alice');
const bob = await connect('bob');
const room = 'general-' + Date.now().toString(36);
let r = await alice.request('join', room, { since: null });
line('alice', `join -> ${r.type} lastSeq=${r.payload.result.lastSeq}`);
r = await bob.request('join', room, { since: null });
line('bob', `join -> ${r.type} members=${r.payload.result.members.map((m) => m.userId).sort()}`);
const presence = await alice.next((e) => e.type === 'presence');
line('alice', `presence <- ${presence.payload.userId} is ${presence.payload.status}`);
const id = newId();
r = await alice.request('message', room, { text: 'Hello, Chatterbox', replyTo: null, attachments: [] }, id);
line('alice', `message -> ack seq=${r.payload.seq} dup=${r.payload.dup}`);
for (const who of [alice, bob]) {
const m = await who.next((e) => e.type === 'message');
line(who === alice ? 'alice' : 'bob', `message <- "${m.payload.text}" from ${m.payload.from.userId} seq=${m.payload.seq}`);
}
r = await alice.request('message', room, { text: 'Hello, Chatterbox', replyTo: null, attachments: [] }, id);
line('alice', `same id again -> ack seq=${r.payload.seq} dup=${r.payload.dup} (no second broadcast)`);
r = await bob.request('edit', room, { target: id, text: 'not my message' });
line('bob', `edit of alice's message -> ${r.type} ${r.payload.code} (${r.payload.message})`);
r = await bob.request('message', 'other-room', { text: 'hi' });
line('bob', `message to a room never joined -> ${r.type} ${r.payload.code}`);
r = await bob.request('history', room, { before: null, limit: 10 });
line('bob', `history -> ${r.payload.result.messages.length} message(s), hasMore=${r.payload.result.hasMore}`);
alice.ws.close(1000);
bob.ws.close(1000);
await sleep(200);
Source: ws: API documentation
Running it against the server prints:
alice join -> ack lastSeq=0
bob join -> ack members=alice,bob
alice presence <- bob is online
alice message -> ack seq=1 dup=false
alice message <- "Hello, Chatterbox" from alice seq=1
bob message <- "Hello, Chatterbox" from alice seq=1
alice same id again -> ack seq=1 dup=true (no second broadcast)
bob edit of alice's message -> error forbidden (only the author may edit)
bob message to a room never joined -> error forbidden
bob history -> 1 message(s), hasMore=false
A longer conformance script covers 23 checks: the hello reply and its s_ id, join with since replay in original
seq order, broadcast to the sender, duplicate ids, replyTo, edit and react rules (including conflict for a second
identical reaction), typing and presence rules, history pagination with before and next, every error code the
server produces, the second-hello conflict, the frame-before-hello and replayed-ticket 4401 closes, the 4426
version close, the 4403 origin close, the 4408 hello timeout, an oversize frame, the rate limit, and the offline
presence broadcast after the grace period. It passed on the Jetty server and, repeatedly, on the Spring Boot variant
below.
The browser client
The browser side is ordinary WebSocket code. Two differences from the other servers in this section follow from how
Atmosphere registers its endpoint, and both were confirmed in Chrome 155:
-
No subprotocol. Atmosphere’s JSR 356 endpoint is registered without any declared subprotocol, so
new WebSocket(url, 'chatterbox.v1')is refused by the browser (the handshake response carries noSec-WebSocket-Protocol, and the close code is 1006). Jetty logs "Client requested Subprotocols on endpoint with none supported". The protocol version is carried by thevfield of every envelope instead. -
Reading the close code. A page served from an origin outside the allow-list does connect, then receives 4403.
<!doctype html>
<meta charset="utf-8">
<title>Chatterbox on Atmosphere</title>
<script>
const q = new URLSearchParams(location.search);
const API = q.get('api') || 'http://localhost:8210';
window.__log = [];
window.__closed = null;
async function connect(user) {
const { ticket } = await (await fetch(`${API}/tickets?user=${user}&name=${user}`, { method: 'POST' })).json();
// No subprotocol argument: Atmosphere's JSR 356 endpoint cannot select one, so the browser would refuse the socket.
const ws = new WebSocket(API.replace('http', 'ws') + '/ws');
const pending = new Map();
ws.onmessage = (ev) => {
const env = JSON.parse(ev.data);
__log.push(env);
const ref = env.payload && env.payload.ref;
if (pending.has(ref) && (env.type === 'ack' || env.type === 'error' || env.type === 'hello')) {
pending.get(ref)(env);
pending.delete(ref);
}
};
ws.onclose = (ev) => { __closed = ev.code; };
const send = (type, room, payload) => new Promise((resolve) => {
const id = crypto.randomUUID().replaceAll('-', '').slice(0, 26).toUpperCase();
pending.set(id, resolve);
ws.send(JSON.stringify({ v: 1, type, id, room, ts: 0, payload }));
});
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = () => reject(new Error('socket failed')); });
const hello = await send('hello', null, { token: ticket, client: 'web/1.4' });
return { ws, send, hello };
}
window.connect = connect;
</script>
Source: MDN: WebSocket
Driving this page in headless Chrome from two static origins gave the expected results: from an allowed origin the
page received the hello reply, joined, sent a message and saw its own broadcast with seq 1; from a second origin
that was not on the list the socket closed with 4403; and passing 'chatterbox.v1' as the second argument produced
close code 1006. On the server the Origin header decides, not the page, so the check holds for any client that
sends one.
The atmosphere.js client and the fallback in action
atmosphere.js 5 is a TypeScript library with no dependencies, published as ESM, CommonJS and a global script. The
old atmosphere-javascript package (last release 1.1.0.RC3 in 2018 on Maven Central) is gone, and so is the jQuery
dependency. Subscribing returns a Subscription with push, close, suspend and resume:
| Request option | Effect |
|---|---|
|
the primary transport: |
|
used when the primary fails; one hop only |
|
sent with posts, for example |
|
parse the length prefix that |
|
perform the Atmosphere handshake (tracking id) before reporting the connection open |
|
the reconnect policy; when it is exhausted |
|
a durable-session token, sent as |
The handlers are open, message, close, error, transportFailure, reconnect, reopen, clientTimeout and
failureToReconnect. The endpoint for this part of the page is the idiomatic Atmosphere shape rather than the
Chatterbox protocol: one URL per room, JSON through an encoder and decoder, the return value broadcast to the room.
package example.chatterbox;
import java.time.Instant;
import org.atmosphere.config.managed.Decoder;
import org.atmosphere.config.managed.Encoder;
import org.atmosphere.config.service.Disconnect;
import org.atmosphere.config.service.ManagedService;
import org.atmosphere.config.service.Message;
import org.atmosphere.config.service.PathParam;
import org.atmosphere.config.service.Ready;
import org.atmosphere.cpr.AtmosphereResource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
/**
* The idiomatic Atmosphere shape: one broadcaster per path, so one URL per room.
* atmosphere.js (or any WebSocket, SSE or long-polling client) talks to /chat/{room}.
*/
@ManagedService(path = "/chat/{room}")
public class RoomChat {
private static final Logger log = LoggerFactory.getLogger(RoomChat.class);
private static final ObjectMapper JSON = JsonMapper.builder().build();
public record Line(String author, String text, String at) {
}
public static final class LineEncoder implements Encoder<Line, String> {
@Override
public String encode(Line line) {
return JSON.writeValueAsString(line);
}
}
public static final class LineDecoder implements Decoder<String, Line> {
@Override
public Line decode(String raw) {
Line in = JSON.readValue(raw, Line.class);
// Never trust the client clock or an oversized text.
String text = in.text() == null ? "" : in.text().substring(0, Math.min(in.text().length(), 4000));
return new Line(in.author(), text, Instant.now().toString());
}
}
@PathParam("room")
private String room;
@Ready
public void onReady(AtmosphereResource r) {
log.info("{} joined {} over {}", r.uuid(), room, r.transport());
}
@Disconnect
public void onDisconnect(AtmosphereResource r) {
log.info("{} left {}", r.uuid(), room);
}
@Message(encoders = LineEncoder.class, decoders = LineDecoder.class)
public Line onLine(Line line) {
return line; // the return value is broadcast to everyone on /chat/{room}
}
}
The test page below subscribes with the WebSocket as primary and a configurable fallback. To see the fallback without
a hostile network, the server was put behind a small reverse proxy that forwards normal HTTP and answers every WebSocket
upgrade with 400 Bad Request, which is what some older middleboxes do.
<!doctype html>
<meta charset="utf-8">
<title>atmosphere.js fallback</title>
<script src="atmosphere.global.js"></script>
<script>
const params = new URLSearchParams(location.search);
window.__events = []; // what the client reports, for the test driver
window.__got = [];
(async () => {
const atm = new AtmosphereJS.Atmosphere({ logLevel: params.get('log') || 'warn' });
window.__sub = await atm.subscribe(
{
url: location.origin + '/chat/lobby',
transport: 'websocket',
fallbackTransport: params.get('fb') || 'long-polling',
contentType: 'application/json',
trackMessageLength: true,
enableProtocol: true,
reconnect: false,
},
{
open: (r) => __events.push('open:' + r.transport),
message: (r) => {
try { __got.push(JSON.parse(r.responseBody)); } catch { /* streaming sends blank padding first */ }
},
transportFailure: (reason, request) => __events.push('transportFailure:' + request.transport),
error: (e) => __events.push('error:' + (e && e.message)),
});
window.__ready = true;
})();
</script>
Loaded in Chrome 155 through that proxy, and sending one message, the three fallbacks behaved identically from the application’s point of view:
fallbackTransport |
Events reported by the client | Message delivered |
|---|---|---|
|
|
yes, to the sender |
|
|
yes, to the sender |
|
|
yes, to the sender |
Without the proxy the same page reports open:websocket. Two behaviours deserve a warning:
-
A streaming connection starts with a block of whitespace padding that reaches the
messagehandler as a message, so the handler must ignore blank payloads (the test page does by catching the JSON parse error). -
The CORS interceptor applies to the HTTP transports. With an
allowedOriginslist that did not include the page’s own origin, the browser’sPOST(it carries anOriginheader even for same-origin requests) returned200and nothing was broadcast. Add the page’s origin to the list.
The Chatterbox endpoint above is written for WebSocket. Over the HTTP transports its AtmosphereResource.write(String)
replies behave differently: the reply to a POST is the response of that POST, and on long-polling its quotes arrived
HTML-escaped ("), because Atmosphere sanitizes the write methods of HTTP transports. Answering through a targeted
broadcast on the same endpoint avoided the escaping but fed the server’s own acks back into @Message, so an application
that needs the fallback transports for a bespoke protocol should answer through a Broadcaster that is not the
endpoint’s own, or use the Room API below, which is built for it.
Rooms and presence: the Room API
Atmosphere 4 adds a higher-level abstraction on top of broadcasters. A Room has a name, member identity
(RoomMember with an id that survives reconnects, unlike the connection uuid), presence events, optional message
history, direct messages to one member, virtual members (bots) and an authorizer. RoomManager.getOrCreate(framework)
returns the registry; room.join(resource, member), room.broadcast(message, senderToExclude),
room.sendTo(message, targetUuid), room.enableHistory(n) and room.onPresence(listener) are the main calls.
RoomProtocolInterceptor bridges clients to rooms with a small JSON protocol (this is what the useRoom hooks of
atmosphere.js for React, Vue and Svelte and the AtmosphereRooms class speak): the client sends join, leave,
broadcast, direct and typing messages, and receives join_ack with the member list, presence, message and
typing. The endpoint needs only the interceptor and the room setup:
package example.chatterbox;
import jakarta.inject.Inject;
import java.util.concurrent.atomic.AtomicBoolean;
import org.atmosphere.config.service.ManagedService;
import org.atmosphere.config.service.Message;
import org.atmosphere.config.service.Ready;
import org.atmosphere.cpr.AtmosphereConfig;
import org.atmosphere.cpr.AtmosphereResource;
import org.atmosphere.room.Room;
import org.atmosphere.room.RoomManager;
import org.atmosphere.room.RoomProtocolInterceptor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The Room API: named groups with member identity, presence events and replayed history.
* RoomProtocolInterceptor turns the join, leave, broadcast, direct and typing JSON messages
* of atmosphere.js (and of any raw client) into Room calls.
*/
@ManagedService(path = "/team", interceptors = RoomProtocolInterceptor.class)
public class TeamRooms {
private static final Logger log = LoggerFactory.getLogger(TeamRooms.class);
private static final AtomicBoolean CONFIGURED = new AtomicBoolean();
@Inject
private AtmosphereConfig config;
@Ready
public void onReady(AtmosphereResource r) {
if (CONFIGURED.compareAndSet(false, true)) {
Room lobby = RoomManager.getOrCreate(config.framework()).room("lobby");
lobby.enableHistory(50); // new members receive the last 50 messages
lobby.onPresence(event -> log.info("{} {} lobby ({} online)",
event.memberInfo() != null ? event.memberInfo().id() : event.member().uuid(),
event.type(), event.room().size()));
}
}
/** Frames that are not room-protocol messages end up here; this endpoint has none. */
@Message
public void onOther(String ignored) {
}
}
Three raw WebSocket clients exercised it (ann, bo and later cy). The wire output, trimmed to what each client
received, shows the protocol and its rules: a broadcast skips its sender, a direct message reaches only its target, and
a late joiner gets the history of the room:
ann <- {"type":"join_ack","room":"lobby","members":[{"id":"ann","metadata":{"displayName":"Ann"}}]}
ann <- {"type":"presence","room":"lobby","action":"join","memberId":"bo","metadata":{"displayName":"Bo"}}
bo <- {"type":"message","room":"lobby","id":1,"from":"ann","data":{"text":"hello team"}}
ann <- {"type":"message","room":"lobby","from":"bo","data":{"text":"psst"}}
cy <- {"type":"message","room":"lobby","id":1,"from":"ann","data":{"text":"hello team"}}
Compared with the hand-written Chatterbox rooms, the Room API gives presence, history replay and identity for free but
fixes the wire format: there is no seq, no acknowledgement, no idempotent ids and no read receipts. Use it when its
protocol is acceptable (an internal tool, a prototype, an atmosphere.js client) and write your own envelope, as
Chatterbox does, when the protocol is a contract with other clients. @RoomService is a shorthand annotation for a
managed service scoped to one room, with maxHistory, and @RoomAuth with a RoomAuthorizer decides per room and
action (JOIN, LEAVE, BROADCAST, SEND_TO) who may do what.
Durable sessions
Without durable sessions a server restart forgets room memberships and broadcaster subscriptions. The
DurableSessionInterceptor gives a connection a token (the response header X-Atmosphere-Session-Token), saves its
rooms and broadcasters in a SessionStore when it disconnects, and restores them when a client presents the token as a
header or as the query parameter X-Atmosphere-Session-Token. Three stores ship: in-memory (development), SQLite for
a single node and Redis for a cluster.
The Spring Boot variant below enables it with a SessionStore bean and one property. It was verified through a real
restart: an ann client got a token, joined the lobby room and disconnected; the JVM was restarted; ann reconnected
with the token, sent no join, and still received the presence and message events that bo produced in the room.
Four conditions came out of the experiment:
-
In 4.0.76 the property
atmosphere.durable-sessions.enabled=trueis required. With only theSessionStorebean no interceptor was installed and no token header was issued, although the tutorial text suggests the bean is enough. -
Restoration re-joins only broadcasters and rooms that exist when the client reconnects. A path-template endpoint creates its Broadcaster lazily, so after a restart it is not there yet; the sample’s pattern is to create the rooms at startup (see
RoomsConfigbelow). -
The token is issued in a response header. The response of a WebSocket upgrade cannot be read by a browser, and the upgrade response captured with
wscarried no such header. Obtain the token from an HTTP request (an SSE or long-polling connection, or any request that passes through Atmosphere) and present it on the WebSocket as a query parameter, which is what the verification did. -
Durable sessions restore subscription state; they do not replay missed messages. That is the job of the
BroadcasterCache, or, as in Chatterbox, of the application’s ownseqand history.
Spring Boot 4
The starter registers AtmosphereServlet, exposes AtmosphereFramework and RoomManager as beans, wires Spring’s
ApplicationContext into Atmosphere’s object factory so that @Inject in a managed service resolves Spring beans, and
scans for annotated classes itself, because embedded servlet containers do not process @HandlesTypes. The package
list in atmosphere.packages is therefore required.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
<relativePath/>
</parent>
<groupId>example</groupId>
<artifactId>chatterbox-atmosphere-boot</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
<atmosphere.version>4.0.76</atmosphere.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.atmosphere</groupId>
<artifactId>atmosphere-spring-boot-starter</artifactId>
<version>${atmosphere.version}</version>
</dependency>
<dependency>
<groupId>org.atmosphere</groupId>
<artifactId>atmosphere-durable-sessions-sqlite</artifactId>
<version>${atmosphere.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
server:
port: 8080
atmosphere:
packages: example.chatterbox
durable-sessions:
enabled: true
servlet-path: /*
init-params:
org.atmosphere.websocket.maxTextMessageSize: "16384"
org.atmosphere.websocket.requireSameOrigin: "false"
org.atmosphere.websocket.WebSocketProtocol.allow1005disconnectEvent: "true"
org.atmosphere.interceptor.HeartbeatInterceptor.heartbeatFrequencyInSeconds: "0"
org.atmosphere.cpr.AtmosphereInterceptor: example.chatterbox.OriginInterceptor
chatterbox.allowedOrigins: https://chat.example.test
org.atmosphere.cpr.cors.allowedOrigins: https://chat.example.test
The init-params map passes any Atmosphere init parameter, which is how the Jetty example’s settings are reproduced.
The other properties of the atmosphere prefix are servlet-path (default /atmosphere/*; here /* so the endpoints
keep their paths), packages, order, session-support, broadcaster-class, broadcaster-cache-class,
websocket-support, heartbeat-interval-in-seconds, enabled, durable-sessions.* and grpc.*.
package example.chatterbox;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class BootApplication {
public static void main(String[] args) {
SpringApplication.run(BootApplication.class, args);
}
/** The demo ticket endpoint; real applications issue tickets from an authenticated controller. */
@Bean
ServletRegistrationBean<Tickets> tickets() {
return new ServletRegistrationBean<>(new Tickets(), "/tickets");
}
}
package example.chatterbox;
import org.atmosphere.cpr.AtmosphereFramework;
import org.atmosphere.room.RoomManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener;
@Configuration
public class RoomsConfig {
private static final Logger log = LoggerFactory.getLogger(RoomsConfig.class);
private final AtmosphereFramework framework;
public RoomsConfig(AtmosphereFramework framework) {
this.framework = framework;
}
@Bean
RoomManager roomManager() {
return RoomManager.getOrCreate(framework);
}
/** Create the rooms before the first client connects: a restored durable session can only rejoin rooms that exist. */
@EventListener(ApplicationReadyEvent.class)
void provisionRooms() {
var lobby = roomManager().room("lobby");
lobby.enableHistory(50);
lobby.onPresence(event -> log.info("{} {} lobby ({} online)",
event.memberInfo() != null ? event.memberInfo().id() : event.member().uuid(),
event.type(), event.room().size()));
}
}
package example.chatterbox;
import java.nio.file.Path;
import org.atmosphere.session.SessionStore;
import org.atmosphere.session.sqlite.SqliteSessionStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SessionStoreConfig {
/** A SessionStore bean is all the starter needs to register the durable-session interceptor. */
@Bean
SessionStore sessionStore() {
return new SqliteSessionStore(Path.of("data/sessions.db"));
}
}
The rest of the classes (ChatterboxEndpoint, Tickets, OriginInterceptor, Ids, RoomChat) are the files from the
Jetty project, unchanged, and TeamRooms shrinks to the annotation and the interceptor because RoomsConfig now sets
the room up. Built with spring-boot-starter-parent 4.1.2 and started with java -jar, the application printed that
the Atmosphere framework 4.0.76 was running on Apache Tomcat 11.0.27, and the 23-check conformance script passed
against it in every run, including the close codes. The starter does not expose BroadcasterFactory as a bean; use
@Inject inside the managed service, or take it from framework.getAtmosphereConfig().
For the Spring Boot side in general see the Spring Boot reference. Atmosphere does not replace Spring’s own WebSocket support; the comparison below says where each fits.
Quarkus
The Quarkus extension scans annotations at build time with Jandex instead of at runtime, registers the servlet and the
JSR 356 endpoints during STATIC_INIT, bridges @Inject to ArC, and reads its configuration at build time
(BUILD_AND_RUN_TIME_FIXED), so changing quarkus.atmosphere.* needs a rebuild. quarkus.atmosphere.load-on-startup
must stay above zero or Quarkus never initialises the servlet. It runs on Undertow, so the application also needs
quarkus-undertow and quarkus-websockets.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-bom</artifactId>
<version>${quarkus.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.atmosphere</groupId>
<artifactId>atmosphere-quarkus-extension</artifactId>
<version>${atmosphere.version}</version>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-undertow</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-websockets</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<version>${quarkus.version}</version>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>build</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
quarkus.http.port=8080
quarkus.atmosphere.packages=example.relay
package example.relay;
import org.atmosphere.config.service.ManagedService;
import org.atmosphere.config.service.Message;
import org.atmosphere.config.service.Ready;
import org.atmosphere.cpr.AtmosphereResource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ManagedService(path = "/atmosphere/chat")
public class Chat {
private static final Logger log = LoggerFactory.getLogger(Chat.class);
@Ready
public void onReady(AtmosphereResource r) {
log.info("{} connected over {}", r.uuid(), r.transport());
}
@Message
public String onMessage(String text) {
return text; // broadcast to every subscriber of /atmosphere/chat
}
}
Built with quarkus-maven-plugin 3.39.5 and started with java -jar target/quarkus-app/quarkus-run.jar, the extension
registered five JSR 356 endpoint paths, and two plain WebSocket clients exchanged a message. The same endpoint was also
subscribed with atmosphere.js 5.0.52. The project documents GraalVM native-image support for both integrations, and
states that its continuous-integration job drives only a long-polling connection through each native binary; WebSocket
and SSE negotiation, codecs and rooms under native image are not asserted.
For Quarkus in general see the Quarkus reference.
Clustering with Redis or Kafka
One Broadcaster exists per node. A broadcast on node A reaches only the connections on node A, so with several nodes
the fan-out needs a relay. Atmosphere ships it as a Broadcaster class: RedisBroadcaster (Lettuce, Redis pub/sub) and
KafkaBroadcaster extend DefaultBroadcaster, publish every broadcast to the external system and deliver
what arrives from other nodes locally. Each instance has a random node id, sent with the message, so a node ignores its
own messages coming back. The Hazelcast and JGroups clusterings, and the JMS, RabbitMQ and XMPP broadcasters, stopped
at 2.6.5 (October 2021); Redis and Kafka are the options for 4.x.
package example.relay;
import org.atmosphere.config.service.ManagedService;
import org.atmosphere.config.service.Message;
import org.atmosphere.plugin.redis.RedisBroadcaster;
/** One broadcaster per room; RedisBroadcaster publishes every broadcast to Redis so other nodes deliver it too. */
@ManagedService(path = "/relay/{room}", broadcaster = RedisBroadcaster.class)
public class RelayChat {
@Message
public String onMessage(String text) {
return text;
}
}
The Redis URL is an init parameter (org.atmosphere.redis.url, Lettuce syntax: redis://, rediss:// for TLS or
redis-sentinel://). Two warnings come from the experiment. Having atmosphere-redis on the classpath is enough to make
RedisBroadcaster the default for every endpoint (the startup log says "This broadcaster will be used by default"), so
a project that only wants Redis for one endpoint should keep the dependency out of the other deployables. And the relay
carries broadcasts, not state: the seq counters, event logs and idempotency cache of the Chatterbox server above live in one
JVM, so a clustered Chatterbox must also move them to a shared store (Redis INCR for seq, a stream or sorted set for
history). See Scaling WebSockets and
the Redis reference.
FROM maven:3.9.16-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
COPY src ./src
RUN mvn -q -B package -DskipTests
FROM eclipse-temurin:21.0.12.1_1-jre
WORKDIR /app
COPY --from=build /src/target/classes ./classes
COPY --from=build /src/target/lib ./lib
EXPOSE 8080
CMD ["java", "-cp", "classes:lib/*", "example.relay.Main"]
name: g3-atmosphere
services:
redis:
image: redis:8.8.3-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 2s
retries: 15
node-a:
build: .
environment:
REDIS_URL: redis://redis:6379
depends_on:
redis:
condition: service_healthy
ports:
- "8215:8080"
node-b:
build: .
environment:
REDIS_URL: redis://redis:6379
depends_on:
redis:
condition: service_healthy
ports:
- "8216:8080"
Source: Compose file reference and Dockerfile reference
docker compose -p relay up -d --build
node cluster-test.mjs # two clients on node A and B in room lobby, one in room elsewhere
docker compose -p relay down -v
Two clients on the two nodes, in the same room, each sent one message; both received both messages, and a client in a different room received nothing:
client on node A got: [ 'hello from node A', 'hello from node B' ]
client on node B got: [ 'hello from node B', 'hello from node A' ]
client on node B, other room, got: []
Choose between the two relays by their guarantees:
| Redis | Kafka | |
|---|---|---|
Delivery |
pub/sub, at most once; a node that is down misses messages |
persisted log, at least once, replayable |
Latency |
sub-millisecond |
higher (batching and commits) |
Operations |
one process or Sentinel |
brokers, KRaft or ZooKeeper, topics |
Settings |
|
|
Both work with the Room API, and neither replaces sticky sessions for the long-polling and streaming fallbacks.
Security
-
Origin. Browsers do not apply the same-origin policy to WebSocket handshakes and send cookies, so every endpoint needs an Origin check or Cross-Site WebSocket Hijacking is possible. Atmosphere 4.0.76 enforces same origin by default (
org.atmosphere.websocket.requireSameOrigin, defaulttrue): a mismatchedOriginis accepted at the HTTP level and the socket is then closed with code 1003 (observed withwssending anOriginheader). A request without anOriginpasses, since only browsers send it. A page on one origin talking to an API on another needs the check turned off and an allow-list of your own, as the example does. -
CORS interceptor. It is installed by default and, without
org.atmosphere.cpr.cors.allowedOrigins, echoes anyOriginback, which the startup log flags for production. Set the list. -
Authentication. Use a one-time ticket, a cookie or session, never a long-lived token in the URL. Enabling
HttpSessionsupport (atmosphere.session-support, or theSessionSupportlistener) is required if the endpoint reads the servlet session. The interceptor chain is the place for an authentication check; returnAction.CANCELLED. -
Output encoding. HTML sanitization was switched off for the WebSocket transport in 4.0.11 because it corrupted JSON frames, while HTTP transports still sanitize
writecalls. Escape message text in the page (textContent, notinnerHTML) whatever the transport. -
Size and rate. Set
maxTextMessageSize; the example’s 16384 bytes closed an oversize frame with 1009 from Jetty. Rate-limit per connection, as the example does, or with an interceptor that returnsCANCELLED. Atmosphere’sBackpressureInterceptor(org.atmosphere.backpressure.highWaterMark, policydrop-oldest,drop-newestordisconnect) protects against slow consumers. -
Admin endpoints. With
atmosphere-adminon the classpath the Spring Boot starter registers/api/admin/*; writes are off by default and need a feature flag, a principal and an authorizer. Do not expose it publicly. -
TLS. Terminate it at the proxy or the container and use
wss://. See Security for the cross-stack checklist.
Common errors and pitfalls
-
@Disconnectnever runs for some clients. A close frame without a status code arrives as 1005, which Atmosphere treats as a timeout instead of a disconnect unlessorg.atmosphere.websocket.WebSocketProtocol.allow1005disconnectEvent=true. Chrome 155 sent 1005 forws.close()with no arguments and 1000 forws.close(1000), and thewslibrary does the same, so ordinary clients are affected. Without the setting the example’s presence and room cleanup silently did not run. -
Garbage frames of
Xevery minute. The defaultHeartbeatInterceptorwrites a padding character to the connection even on WebSocket. A JSON-only client breaks; setheartbeatFrequencyInSecondsto 0 and run an application heartbeat. -
The browser refuses the socket when you pass a subprotocol. Atmosphere does not negotiate one. Omit the argument.
-
An endpoint keeps no state. Without
@Singletonthe class is instantiated for each connection, so instance fields are per connection. -
A broadcast comes back as an inbound message. A non-
RawMessagebroadcast runs through@Message; wrap pre-encoded text inRawMessage. Even a targeted broadcast on the endpoint’s own broadcaster re-entered the handler in the experiment above. -
Filters never fire. On the managed path a
BroadcastFiltersees the encoded message, not your domain class. -
Nothing works on Quarkus.
quarkus.atmosphere.load-on-startupis zero or negative, orquarkus.atmosphere.packagesis missing. -
No endpoints found on Spring Boot.
atmosphere.packagesis not set. -
jakarta.inject.Singletonignored. The annotation isorg.atmosphere.config.service.Singleton. -
JSON through the fallback transports comes back as
". A directwriteis sanitized on HTTP transports; send through a broadcast of aRawMessagethat does not re-enter the endpoint. -
Restart loses rooms although durable sessions are configured. The property
atmosphere.durable-sessions.enabledis false by default and the rooms must exist before the client reconnects. -
Some clients cannot connect and the CORS list looks right. The CORS interceptor also judges same-origin
POSTrequests from the fallback transports. -
Order on the wire. The ack of a message and its broadcast can arrive in either order; clients must sort by
seq.
Atmosphere compared with plain Jakarta WebSocket and with Spring
Jakarta WebSocket 2.2 (the specification of Jakarta EE 11, API 2.2.0) is the standard Java server API: a
@ServerEndpoint class with @OnOpen, @OnMessage, @OnClose and @OnError, a Session per connection, text,
binary and partial messages, path parameters, encoders and decoders, and subprotocol and extension negotiation.
Atmosphere is built on top of it for its WebSocket transport. The two can share one server; this @ServerEndpoint was added to
the Jetty project of this page and answered on /plain while Atmosphere served /ws:
package example.chatterbox;
import jakarta.websocket.OnClose;
import jakarta.websocket.OnMessage;
import jakarta.websocket.OnOpen;
import jakarta.websocket.Session;
import jakarta.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/** The same fan-out written against plain Jakarta WebSocket 2.2: no transports, no broadcaster, but subprotocol negotiation. */
@ServerEndpoint(value = "/plain", subprotocols = "chatterbox.v1")
public class PlainEcho {
private static final Set<Session> SESSIONS = ConcurrentHashMap.newKeySet();
@OnOpen
public void onOpen(Session session) {
session.setMaxTextMessageBufferSize(16384);
SESSIONS.add(session);
}
@OnMessage
public void onMessage(String text) throws IOException {
for (Session s : SESSIONS) {
if (s.isOpen()) {
s.getAsyncRemote().sendText(text); // note: no per-session send queue, see the pitfalls
}
}
}
@OnClose
public void onClose(Session session) {
SESSIONS.remove(session);
}
}
A client that requested chatterbox.v1 got the subprotocol chatterbox.v1 selected by /plain, and the same request to Atmosphere’s
/ws was refused. The trade-offs:
Jakarta WebSocket (@ServerEndpoint) |
Atmosphere | Spring (MVC or WebFlux) | |
|---|---|---|---|
Transports |
WebSocket only |
WebSocket, SSE, streaming, long-polling, WebTransport, gRPC with a client that falls back |
WebSocket with optional SockJS fallback (Servlet stack only) |
Fan-out |
write it yourself over the sessions |
Broadcaster, rooms, filters, caches |
|
Clustering |
write it yourself |
Redis and Kafka broadcasters |
external STOMP broker relay, or your own Redis pub/sub |
Subprotocol negotiation |
yes |
no, with the JSR 356 binding |
yes |
Client library |
none |
|
|
Backpressure |
per-session queues are yours |
|
Reactor backpressure on WebFlux |
Choose when |
a simple endpoint, standards compliance, a custom subprotocol |
Java, and clients on networks that may block WebSocket, or rooms and presence out of the box |
the rest of the system is Spring |
Fan-out written directly over the Session set (as PlainEcho does) has a classic problem: nothing queues the
messages of a slow client or decides what to drop, so depending on the container the loop blocks or the memory grows, and
every server has to invent its own policy. Broadcasters and BackpressureInterceptor exist to solve that. For the Spring side see
Spring WebFlux WebSockets, and for STOMP on the Servlet stack
the Thymeleaf SSE and WebSocket page.
Migrating from 2.x and 3.x to 4.x
| Area | Before | Atmosphere 4 |
|---|---|---|
JDK |
8 or 11 |
21 minimum |
Servlet and injection |
|
|
Removed |
Jersey, GWT, Socket.IO and CometD modules, SwaggerSocket, |
use |
Clustering |
Hazelcast, JGroups, JMS, RabbitMQ, XMPP (2.6.5) |
|
JavaScript client |
|
|
Groups |
manual Broadcaster management |
the Room API |
Spring Boot |
community integration |
official starter for Spring Boot 4 (and a Boot 3 starter) |
Quarkus |
none |
official extension |
Threads |
a pool to size |
virtual threads by default ( |
JSON |
Jackson 2 ( |
Jackson 3 ( |
The annotation model did not change, so @ManagedService classes usually need only the package rename. Client code
changes from the global atmosphere object with onMessage callbacks to the promise-based subscribe(request, handlers):
import { Atmosphere } from 'atmosphere.js';
const atm = new Atmosphere();
const sub = await atm.subscribe(
{ url: '/chat', transport: 'websocket', fallbackTransport: 'long-polling' },
{ message: (response) => console.log(JSON.parse(response.responseBody)) });
sub.push(JSON.stringify({ author: 'ann', text: 'hello' }));
Version and specification notes
-
Atmosphere. 4.0.0 on 2026-02-20; 4.0.76 on 2026-10-22; there were 76 patch releases in eight months. The project’s changelog is the authoritative history. Pin the version and read the release notes when you upgrade; this page’s observations (the default-on same-origin check, the 1005 handling, the property for durable sessions) are behaviours of 4.0.76.
-
Jakarta. Jakarta WebSocket 2.1 belongs to Jakarta EE 10 and 2.2 to Jakarta EE 11. The Jetty
ee10modules used here resolvedjakarta.websocket-api2.1.1 while Atmosphere compiles against 2.2.0. -
Protocol standards. The WebSocket protocol is RFC 6455; browsers may send close codes 1000 and 3000 to 4999 only, so 1005 and 1006 never travel on the wire from a page, although a client that sends no code is reported as 1005. See The WebSocket Protocol.
-
Where to go next. Building a Chat App assembles Chatterbox end to end, and Comparison and Decision Guide puts this stack next to the others.