Subscriptions, WebSocket streams and gRPC — beyond simple request / response.
What you'll learn
≈45 min · Intermediate · builds on Testing RPC handlers
Request-and-response is only the beginning. In this module you'll go further: subscriptions and WebSocket streams that push updates as they happen, batch requests, and the basics of the gRPC path. These are the features behind the real-time explorers, wallets and dashboards built on XRPL.
In brief: send large results incrementally instead of all at once.
Streaming lets a handler send multiple response chunks over a single connection, useful for large result sets or real-time updates. Three patterns exist:
| Pattern | Transport | When to use |
|---|---|---|
| Progress messages | WebSocket | long-running queries, keep the client informed |
| Pagination with a marker | any | large result sets, client-controlled pace |
| Chunked encoding | HTTP | large single responses without WebSocket |
The pagination pattern is the one you will meet everywhere in rippled's own handlers (account_lines, account_objects, ledger_data):
Json::Value doPagedStream(RPC::JsonContext& context)
{
// 1. Read and cap the page size
unsigned int pageSize = std::min(
context.params.get("limit", 100).asUInt(), 1000u);
// 2. Resume where the previous page stopped
std::string marker = context.params.get("marker", "").asString();
// 3. Fetch one page from the ledger ...
// 4. If more results remain, return a new marker
Json::Value response;
response[jss::status] = jss::success;
response["items"] = items;
if (!nextMarker.empty())
response["marker"] = nextMarker; // client sends it back for page 2
return response;
}
What this code shows:
marker carries the positionIn brief: push updates to clients as ledgers, transactions, and validations happen.
A client subscribes once, then receives typed push messages. The three core streams:
| Stream | Push message type |
Fired when |
|---|---|---|
ledger |
ledgerClosed |
a ledger closes (index, hash, fees, txn count) |
transactions / accounts |
transaction |
a transaction is applied (tx JSON + metadata) |
validations |
validationReceived |
a validation message arrives from a validator |
A ledgerClosed push looks like this:
{
"type": "ledgerClosed",
"ledger_index": 12345,
"ledger_hash": "...",
"fee_base": 12,
"reserve_base": 10000000,
"txn_count": 45
}
The real implementation lives in src/xrpld/rpc/handlers/subscribe/Subscribe.cpp; the shape is:
Json::Value doSubscribe(RPC::JsonContext& context)
{
// 1. At least one subscription target is required
if (!context.params.isMember("streams") &&
!context.params.isMember("accounts"))
return rpcError(RpcInvalidParams);
// 2. Validate each stream name against the known set
// (ledger, transactions, validations, server, ...)
// 3. Validate each account with parseBase58<AccountID>
// and reject the whole request on the first bad one
// 4. Register the subscriptions with InfoSub
// (the transport layer owns the connection state)
Json::Value response;
response[jss::status] = jss::success;
return response;
}
What matters here:
InfoSub doesunsubscribe mirrors the same shape and removes the entriesKey idea. Subscriptions flip the model: instead of the client polling, the node pushes updates as they happen. This is what powers live explorers and wallets.
In brief: handle several commands in a single request.
A batch is simply an array of standard requests, each with its own id. Note that rippled itself does not accept JSON-RPC batch arrays natively: batching is a client- or proxy-side pattern, and the processor sketched below is what such a gateway implements:
[
{ "method": "account_info", "params": [{"account": "rN7n..."}], "id": 1 },
{ "method": "ledger", "params": [{"ledger_index": "validated"}], "id": 2 }
]
The processor loops over the array, dispatches each entry through the normal handler table, and collects the results:
Json::Value processBatchRequests(RPC::JsonContext& context)
{
Json::Value batchResults(Json::arrayValue);
for (auto const& request : context.params)
{
// build a child JsonContext for this entry,
// look up the handler by request["method"], run it,
// and catch exceptions so one failure never kills the batch
Json::Value itemResult = /* dispatch(request) */;
itemResult["id"] = request.get("id", Json::Value::null);
batchResults.append(itemResult);
}
return batchResults; // one array in, one array out
}
Rules a good batch processor follows:
id so responses can be matched out of orderIn brief: the gRPC path alongside JSON-RPC and WebSocket.
rippled also exposes a binary gRPC API, defined in protobuf under include/xrpl/proto/org/xrpl/rpc/v1. A service is a set of typed request/response pairs:
service XRPLedgerAPIService { // the real name in xrp_ledger.proto
rpc GetAccountInfo(GetAccountInfoRequest)
returns (GetAccountInfoResponse);
}
message GetAccountInfoRequest {
string account = 1;
string ledger_index = 2;
}
message GetAccountInfoResponse {
string balance = 2;
uint32 sequence = 3;
bool validated = 5;
}
The server-side implementation is a thin adapter around the JSON handlers:
grpc::Status GetAccountInfo(grpc::ServerContext* ctx,
GetAccountInfoRequest const* request,
GetAccountInfoResponse* response) override
{
// 1. Convert the protobuf request into Json::Value params
// 2. Build an RPC::JsonContext and call doAccountInfo()
// 3. Map a JSON error onto grpc::StatusCode::INVALID_ARGUMENT
// 4. Copy the JSON fields into the typed protobuf response
response->set_balance(result["balance"].asString());
return grpc::Status::OK;
}
Three things to remember about the gRPC path:
[port_grpc]), separate from HTTP/WS.proto filesWhen a ledger closes or a transaction applies, the server side walks the subscriber lists and pushes the typed message to every matching connection:
void broadcastLedgerClosed(std::shared_ptr<Ledger const> const& ledger,
NetworkOPs& netOps)
{
Json::Value message;
message["type"] = "ledgerClosed";
message[jss::ledger_index] = ledger->info().seq;
message["ledger_hash"] = to_string(ledger->hash());
netOps.broadcastMessage(message); // fan-out to subscribers
}
Transaction broadcasts work the same way, but fan out only to the connections subscribed to the affected accounts.
Advanced features multiply load, so every one of them ships with a guard rail:
| Guard rail | Typical value | Protects against |
|---|---|---|
| Max items per streamed page | 1,000-10,000 | one query monopolising the node |
| Max subscriptions per connection | ~100 | subscriber list explosion |
| Slow-consumer drop | transport-level | one lagging client blocking the fan-out |
| Shared ledger per batch | 1 lookup | repeated ledger resolution |
The pattern is always the same: cap the work per request, push the continuation cost back to the client (markers), and never let one connection degrade the others.
This module went beyond simple request and response. You saw subscriptions and WebSocket streaming, where the node pushes updates as ledgers, transactions, and validations happen, along with batch requests and the basics of the gRPC path. These are the features behind the real-time explorers, wallets, and dashboards built on XRPL.
To remember:
subscribe streams: ledger, transactions, validations, server, plus per-account variantsledgerClosed, transaction, validationReceivedorg.xrpl.rpc.v1) on their own portInfoSub is the server-side subscription machineryNext up. RPC closed. New phase, the heart of the system: before studying how nodes agree, look closely at the thing they agree ON. Next: ledger architecture and data structures.
Resources
Assignments
0 of 2 complete