RCWebDocs

Architecture reference

How RCWeb works

RCWeb serves browser apps over HTTP and relays executable JavaScript between browsers in the same virtual room over WebSockets. Browsers own application behaviour; the Java server owns connections, room membership and message routing.

Reviewed Java 21 & browser JavaScript
Browser
Owns application state
Java relay
Routes within a room
WebSocket
Carries JavaScript

Reflects the current implementation and defaults. Capacity depends on the workload and deployment.

System boundaries and state

#
A desktop, laptop and phone connect bidirectionally to one RCWeb relay inside a shared virtual room.
Figure 01One room, several browsers. Each browser connects to the server, which routes JavaScript to matching recipients in that room. Browsers execute it and update their own app state and UI. The enclosure represents logical membership, not a shared physical location; ordinary RCWeb messages do not travel directly between peers.
View full size

The relay does not interpret game moves, merge documents or store an authoritative app snapshot. It does retain operational state. Calling the whole backend “stateless” obscures both reconnect behaviour and the constraints on running multiple server instances.

Where state lives
OwnerResponsibilities and lifetime
Browser applicationUI, game rules, shared data, authority selection and conflict resolution. Persistence is app-specific; in-memory state disappears when its owning page closes or reloads.
Browser runtimeRoom/client identifiers, the socket, reconnect timers and a bounded outgoing queue. This is transport support, not a shared-state database.
Java relayIn-memory rooms, client slots, request metadata, sockets and pending commands. A server restart loses this state.
Supporting servicesResource/proxy caches, monitoring data, pending file chunks and MCP result callbacks have their own lifetimes. These are separate from application state.

HTTP serving and Java components

#

Main loads settings, configures certificate handling, installs RootResponder and starts HttpServer. Each accepted TCP connection gets an HttpHandler on a virtual thread. The handler uses SocketSniffer to select plain or TLS transport, then reads requests through a SuperSocket.

HttpHandler parses HttpRequest, calls the responder and writes the resulting HttpResponse. It can handle multiple HTTP requests on a keep-alive connection. A WebSocket upgrade transfers the connection to WebSocketResponse for its live read/write loops.

Main server components
ComponentRole and relationships
RootResponderRoutes static files, app setup, WebSocket upgrades and service endpoints. Uses ResponseCache for resources and delegates specialised requests to their handlers.
RoomManagerProcess-local singleton registry: a HashMap protected by a ReentrantLock. Atomically looks up/creates a room and attaches a client; runs background maintenance.
RoomOwns client membership, target selection and broadcast. Uses a CopyOnWriteArrayList with a membership lock for attachment and expiry.
ClientA reusable slot identified by client ID within a room. Holds the current request, direct-send handler and fallback command queue.
ClientRequestOne connection's room, app, client ID, IP address, user agent, start/end times and connection reference. A reconnect replaces the request on a retained slot.
ClientWebSocketHandlerValidates the app connection request, attaches the client and supplies the WebSocket response callbacks for incoming commands, outgoing updates and closure.
ClientCommandAllow/deny target lists plus JavaScript bytes in a ReusableBufferList. Parses the routing header; does not evaluate the payload on the server.

Resources and URL mapping

An app URL such as /chat/ resolves to /app/chat/index.html in the resource sources. RootResponder checks the classpath first, followed by site.zip, apps.zip and immutable.zip. During development, the source directories can be placed directly on the classpath.

ZIP sources have different cache policies: site content gets a ten-minute cache lifetime; immutable content gets a one-year immutable policy. Development refresh behaviour also depends on resourceCacheEnabled. App and documentation edits do not require a Java compile.

Browser connection lifecycle

#
  1. Load the app. For example, open /chat/?r=test-0001 in two tabs. Load /assets/core/comms.js before the app script.
  2. Register handlers. Expose remote functions globally and install hooks such as rc.onConnected before calling rc.connect().
  3. Fetch setup. The runtime reads r from the URL, falling back to localStorage.rcwebRoom when the parameter is absent. It injects a relative setup.js script request. The server preserves a valid room ID or generates a new one, and generates a fresh client ID.
  4. Open the socket. Setup assigns rc.version, rc.app, rc.room, rc.client and rc.commsWebSocket, then calls rc.listen(). The endpoint is /<app>/app.websocket?r=<room>&c=<client>, using ws or wss according to the setup request's transport.
  5. Attach and initialise. The server attaches a client slot and upgrades the connection. On socket open, the runtime calls rc.onConnected(), then flushes its outgoing queue. Use this repeatable hook to request current app state and rebuild room-specific UI.
  6. Recover after interruption. The runtime reconnects using the existing setup and client ID. A full page load fetches new setup and therefore gets a new client ID; it is not a durable player identity.

Valid room IDs contain 4–11 ASCII letters, digits or hyphens; generated IDs use the xxxx-xxxx format. Client IDs contain exactly eight characters from the same accepted character classes. Room lookup is case-sensitive. Invalid room IDs at setup are replaced, while invalid room/client IDs on the WebSocket request are rejected.

Message format and routing

#

rc.send(js, target) sends a WebSocket text message containing a selector line, a CRLF, then executable JavaScript. ClientCommand.readRaw() splits the header from the body; Room.broadcast() selects matching clients in that room.

WebSocket payload
chat,!abcd2345
chat.receiveMessage("efgh6789", "Hello from another browser");

In this example, every chat client except abcd2345 is selected. The selector header is not sent to recipients; the JavaScript is delivered with a server version-update prefix.

Selectors within a room
SelectorMatches
chatAn exact app name.
abcd2345An exact client ID.
*Every client, including the sender if it matches.
chat,notepadEither app.
spacewar* / *-cApp-name prefix / suffix matching; these are not general glob patterns.
chat,!abcd2345Chat clients except the listed client.
!c,*-cAll clients except app c and apps ending in -c.

There is one ! separator: everything after it belongs to the deny list, which overrides the allow list. An empty allow list means all clients are initially eligible. The relay does not automatically exclude the source or attach an authenticated sender identity to the payload.

For ordinary remote function calls, use the serialization helper with an application-controlled function name:

Remote function call
// The receiving app defines chat.receiveMessage globally.
// Run after connection; include the sender ID if the app needs it.
rc.sendFunctionCall('chat', 'chat.receiveMessage', rc.client, 'Hello');

The helper JSON-serializes arguments. The receiver's rc.runJavaScriptUpdate() executes the resulting source with window.eval and calls its local success/error hook. Serialization avoids hand-built argument escaping; it does not make the execution channel a sandbox.

Delivery, threads and queues

#

WebSocketResponse starts a virtual reader thread; the existing HTTP handler thread becomes the writer. Incoming text calls ClientCommand.readRaw() and then Room.broadcast().

Direct delivery sends a command straight to a browser. Queued fallback sends it through a client queue and writer before reaching the browser.
Figure 02Two server delivery paths. A command normally uses the direct-send handler. If that handler is unavailable or fails, the server queues the command for the client's writer. This queue is separate from the browser's offline send queue. Neither path confirms execution in the browser.
View full size
  1. Direct delivery: for each selected client, Client.sendCommand() first invokes its direct-send handler. A successful write bypasses the queue.
  2. Fallback delivery: if no handler exists or it throws, the command is appended to the client's queue. Membership notifications explicitly use this queue.
  3. Queued writer: Client.awaitCommand() waits using a ReentrantLock and Condition. Enqueue, closure and request replacement wake it. Socket writes happen after the command lock is released.
  4. Idle updates: after a 29.5-second queue wait without a command, the writer emits rc.onNoUpdate(). Outgoing commands carry rc.onUpdate(version); the default browser hook reloads the page on a version mismatch.

The WebSocket write lock prevents concurrent writers from interleaving frame bytes. It does not establish an application-wide ordering across senders or between direct writes and queued commands. Direct sends also run in the calling thread, so a slow recipient can delay that broadcast's remaining writes.

There are two separate queues. The browser's offline send queue defaults to 200 messages and drops the oldest when full; it flushes after rc.onConnected(). The server's per-client fallback queue currently has no explicit size bound. Neither survives the process/page that owns it restarting.

Room lifecycle and limits

#

RoomManager.attachClient() holds the registry lock while looking up or creating a room and attaching its client. The lock order is registry then room membership. This prevents eviction between lookup and attachment; maintenance sends membership notifications outside the registry lock.

A reconnect with the same ID reuses a retained client slot, replaces its request and wakes the old writer. A late close of the old request does not end the replacement. Once maintenance removes the slot, its queued commands are no longer available to a reconnecting browser.

Current lifecycle values
Limit or intervalBehaviour
32 client slots per roomRoom.MAX_CLIENTS includes retained reconnect slots. An existing ID can reattach at capacity. A new ID receives a room-full message and WebSocket close code 1008; the supplied message stops automatic retries and asks the user to reload after freeing space.
1-second maintenance cycleWhen backgroundMaintenance is enabled, the manager cleans membership and checks empty rooms.
500 ms reconnect graceEnded requests are retained until a maintenance pass finds them older than the cutoff. Removal is not an exact 500 ms timer. Open requests are not evicted just for being idle.
60-second empty-room retentionAfter the last slot is removed, a room remains in the registry until the retention period elapses and maintenance evicts it.
5-second browser connect timeoutThe runtime times out the opening attempt and schedules a retry. Retry delays have a 1-second minimum.
1 MiB frame/message defaultsSettings.websocketMaxFrameSize and websocketMaxMessageSize bound inbound WebSocket data. These are configurable transport limits, not recommended app message sizes.

Membership changes enqueue rc.onUpdateClients([...]) with client IDs. This view can include recently disconnected slots during the grace period. MCP's list_clients instead filters for currently open connections and includes app names. On JVM shutdown, Main asks the manager to close room sockets, then stops the HTTP server.

Application state and recovery

#
One authority: a viewer holds state and phones supply controls. Shared copies: three peer browsers each hold a copy of state.
Figure 03Choose who owns shared state. An asymmetric app gives the viewer authority; a symmetric app keeps copies that peers must reconcile. This illustration shows state ownership, not network links: both patterns can communicate through the RCWeb relay.
View full size

Asymmetric apps: viewer and controllers

The viewer owns authoritative state; phone controllers send intents and receive snapshots or replies. The viewer validates actions and decides their order. Supporting multiple viewers requires an explicit authority rule. Controller handoff, room-preserving navigation and the standard QR invitation panel are application responsibilities, described in the app implementation guide.

Symmetric apps: collaborating peers

Peers each maintain a local copy. On join or reconnect, request a snapshot and apply a deterministic revision/merge rule so a delayed reply cannot overwrite newer edits. Resolve concurrent changes in app code; broadcasting updates alone does not provide consistency.

Design for interruptions

A server restart discards relay state. Browsers may reconnect with their existing in-memory state, but the server cannot reconstruct a game or document for them. A server version change can also trigger browser reloads, so recovery cannot depend solely on pages staying open.

Files, WebRTC, MCP and monitoring

#

RootResponder hosts several paths alongside the app relay. Their data flows and guarantees differ.

See the endpoint reference for request details and MCP integration guide for the external tool interface.

Trust, deployment and performance

#

Room access is a trust boundary

Room codes provide access to cooperating browsers. App names and client IDs are routing identifiers, not account authentication or role permissions. A participant able to send JavaScript into a room can execute code in matching pages; the receiver runs it in the page's context. Treat room links and QR codes as access information, and use this model only where participants are trusted to control those pages.

Use HTTPS/WSS for transport protection. It does not isolate participants from each other or make relayed code safe. Validate app-level arguments and render plain user text with textContent, while recognising that these checks do not constrain a peer that sends arbitrary JavaScript.

One room must reach one relay instance

The registry and queues are local to one JVM; the current room relay has no cross-instance replication. If deploying multiple instances, all clients of a room need routing to the same instance. Independent per-browser session affinity is insufficient. File requests and MCP operations must also reach the instance holding the target connection and any pending callback state.

A reverse proxy must support WebSocket upgrades and long-lived connections. Account for the generated socket URL: setup derives its host from the HTTP Host header and its scheme from whether the server-side socket is secure. Verify those values when terminating TLS upstream.

Measure capacity instead of assuming it

Virtual threads support blocking connection handling without allocating an operating-system thread for every connection. They do not remove socket buffers, TLS work, payload memory, queue growth or network limits. Routing scans room clients, and each broadcast writes to every selected recipient, so message size, frequency and fan-out determine much of the cost.

Throttle continuous controls, send compact state updates and keep large files off the JavaScript channel. Measure latency, heap, pending work and connection failures with realistic recipients and slow devices. The unbounded server fallback queue and direct writes to slow clients deserve particular attention in load testing; this architecture alone does not establish a supported connection count.

Source map and further reading

#

Find the implementation by responsibility. Repository paths show where to work; API links open the corresponding Javadoc.

Further reading

Browse the complete Java API reference for class and method details.

For implementation recipes and runtime APIs, start with the app development guide. For service contracts, use the endpoint reference. For an individual app, consult its appinfo.md for usage and its local AGENTS.md for implementation details.