JSON-RPC 2.0 is not an HTTP API. It's a message format. The spec's first paragraph says so directly: "It is transport agnostic in that the concepts can be used within the same process, over sockets, over http, or in many various message passing environments."
This means the same {"jsonrpc":"2.0", "method":"subtract", "params":[42,23], "id":1} bytes work identically whether you send them over HTTP POST, a WebSocket frame, a Unix domain socket, a TCP stream, or piped through stdin/stdout of a child process. Your application logic — the method registry, the request validation, the response construction — stays the same. Only the plumbing around it changes.
That sounds like a minor detail. It isn't. Transport choice determines whether your system supports server push, how request correlation works, what your latency looks like, and which security guarantees you get for free. This article covers the three transports that matter in practice.
1. HTTP — The Default Choice
Most JSON-RPC implementations run over HTTP. The mapping is straightforward: every JSON-RPC request is an HTTP POST to a single endpoint. Every response comes back as the HTTP response body.
1.1 The rules
POST only. Unlike REST, where GET, PUT, DELETE, and PATCH each carry semantic meaning, JSON-RPC uses POST for everything. The method name inside the JSON body is what determines the operation, not the HTTP verb. GET is never used because JSON-RPC requests have a body.
Content-Type: application/json is mandatory. The server should reject any request that doesn't include this header. This isn't just pedantic — it has a direct security benefit, which we'll cover below.
1.2 The "200 OK" controversy
This is the single most confusing behavior in HTTP-based JSON-RPC.
When a JSON-RPC method fails — say, a "Method not found" error — the HTTP response status is still 200 OK. The error lives inside the JSON body, not in the HTTP status code.
// HTTP response status: 200 OK
// But the body contains an error:
{
"jsonrpc": "2.0",
"error": {
"code": -32601,
"message": "Method not found"
},
"id": 1
}
Why? Because from the HTTP transport's perspective, nothing went wrong. The client sent a request, the server received it, processed it, and sent back a well-formed response. The fact that the application-layer result is an error is irrelevant to the transport layer. HTTP did its job.
This is consistent with the spec's transport agnosticism. If your JSON-RPC messages are flowing over WebSocket or stdin, there are no HTTP status codes at all. The error handling must work identically at the JSON-RPC level regardless of transport. Encoding error semantics into HTTP status codes would break that abstraction.
The practical impact: AWS load balancer health checks, Pingdom, Datadog HTTP monitors, Nginx upstream health checks — they all look at the HTTP status code. If your JSON-RPC server returns 200 for every response, these tools will report the service as healthy even if every single RPC call is failing with -32603 Internal error.
The pragmatic compromise that some implementations adopt: return HTTP 500 for protocol-level errors (-32700 Parse error, -32600 Invalid Request) and HTTP 200 for application-level errors. This technically breaks transport agnosticism — the JSON-RPC response should be self-contained — but it makes external monitoring work. The JSON-RPC over HTTP specification (a separate, non-normative document) suggests this approach.
The correct solution, regardless, is to monitor at the JSON-RPC layer: parse every response body, check for the error field, and emit metrics on error.code. Don't rely on HTTP status codes.
1.3 CSRF immunity — the free security win
Here's a side benefit of the Content-Type: application/json requirement.
Cross-Site Request Forgery (CSRF) attacks work by tricking a user's browser into making a request to a target site. The classic vector is an HTML <form> with action="https://target.com/api" and method="POST". When the user visits the attacker's page, the form auto-submits using the user's cookies.
But HTML forms can only set Content-Type to three values: application/x-www-form-urlencoded, multipart/form-data, and text/plain. They cannot set it to application/json. This means if your JSON-RPC server strictly rejects any request where Content-Type is not application/json, form-based CSRF attacks are impossible.
Implementation note
This defense works because application/json is not a "simple" content type under the CORS spec, which means cross-origin requests with this header trigger a preflight OPTIONS request. The attacker's page cannot skip the preflight. Combined with proper CORS configuration, this is a strong mitigation for classical form CSRF on those JSON endpoints, but not a universal replacement for CSRF defenses.
1.4 The limitation: no native bidirectional push
HTTP APIs are client-initiated, so without streaming you often end up polling (anything new?). Polling adds latency, increases load, and wastes bandwidth.
HTTP can still deliver push-like updates through long polling, SSE, or streamable responses over one open connection. Model Context Protocol (MCP) reflects this evolution: earlier transport guidance used SSE, while newer versions favor Streamable HTTP for better connection handling.
But for low-latency, bidirectional JSON-RPC in high-frequency blockchain/crypto systems, WebSocket is typically the preferred transport. This is where WebSocket enters.
2. WebSocket — Where JSON-RPC Shines
A WebSocket connection starts as an HTTP request (the "upgrade handshake"), then switches to a persistent, full-duplex TCP connection. Both client and server can send messages at any time without waiting. The connection stays open until one side explicitly closes it.
For JSON-RPC, this changes three things fundamentally.
2.1 Bidirectional communication
The server can send JSON-RPC messages to the client without being asked. In Ethereum, this is how eth_subscribe works: the client sends a single subscription request, and the server pushes a notification for every new block, every matching log event, or every pending transaction — as they happen, in real-time.
You can try this yourself using wscat:
# 1. Install wscat
npm install -g wscat
# 2. Open connection to a public node
wscat -c wss://ethereum.publicnode.com
# 3. Send subscription request (regular request with id)
> {"jsonrpc":"2.0", "id":1, "method":"eth_subscribe", "params":["newHeads"]}
# 4. Server confirms (regular response with id)
< {"jsonrpc":"2.0", "id":1, "result":"0x9ce59a13059e417087c02d3236a0b1cc"}
# 5. Server pushes notifications (no id — these are NOT responses)
< {"jsonrpc":"2.0", "method":"eth_subscription", "params":{
"subscription": "0x9ce59a13059e417087c02d3236a0b1cc",
"result": {"number":"0x1456789", "hash":"0xabc...", ...}
}}
Notice the structure of those push messages: they have method and params but no id. They look like JSON-RPC notifications — fire-and-forget messages from the server side. The server doesn't expect a response to these. Notifications, which we covered in Article 1, are exactly the right abstraction for server push.
Common Ethereum subscriptions are newHeads, logs, and newPendingTransactions (plus syncing), with pending-tx coverage depending on client/provider configuration:
newHeads— a notification every time a new block is added to the chain (~12 seconds). Used by wallets to update the latest block number.logs— filtered event logs from smart contracts. Used by dApps to react to on-chain events (like token transfers) in real-time.newPendingTransactions— notifications for transactions entering the node’s pending pool (typically as tx hashes; exact coverage depends on client/provider). Used by MEV searchers, trading bots, and gas estimators to react before block inclusion.
2.2 The id correlation problem
With HTTP, correlation is trivial. You send one request on a connection, you get one response on the same connection. The response is implicitly matched to the request by the connection itself.
With WebSocket, one connection carries everything. Multiple requests are in-flight simultaneously. Responses can arrive in any order — a fast query might return before a slow one, even if it was sent later. Server-pushed notifications are mixed in with responses to your requests on the same stream.
The id field is the only way to tell what's what.
The standard implementation pattern is a Map that stores pending requests keyed by their id:
class WebSocketRpcClient {
constructor(url) {
this.pending = new Map();
this.nextId = 1;
this.ws = new WebSocket(url);
this.ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
// Is this a response to one of our requests?
if (msg.id !== undefined && msg.id !== null) {
const handler = this.pending.get(msg.id);
if (handler) {
this.pending.delete(msg.id);
if (msg.error) handler.reject(msg.error);
else handler.resolve(msg.result);
return;
}
}
// No id → server-pushed notification
this.onNotification?.(msg);
};
}
call(method, params) {
const id = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.ws.send(JSON.stringify({ jsonrpc: '2.0', method, params, id }));
});
}
}
// --- Usage ---
const client = new WebSocketRpcClient('wss://ethereum.publicnode.com');
client.onNotification = (msg) => console.log('New Head:', msg.params.result.hash);
client.ws.onopen = async () => {
const block = await client.call('eth_blockNumber');
console.log('Current block:', parseInt(block, 16));
await client.call('eth_subscribe', ['newHeads']);
};
import asyncio, json
import websockets
class WebSocketRpcClient:
def __init__(self, url: str):
self.url = url
self.pending: dict[int, asyncio.Future] = {}
self.next_id = 1
self.ws = None
self.on_notification = None # Set this to handle pushes
async def connect(self):
self.ws = await websockets.connect(self.url)
asyncio.create_task(self._listen())
async def _listen(self):
async for raw in self.ws:
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
# Is this a response to one of our requests?
req_id = msg.get("id")
if req_id is not None:
future = self.pending.pop(req_id, None)
if future:
if "error" in msg:
future.set_exception(Exception(msg["error"]["message"]))
else:
future.set_result(msg["result"])
continue
# No id → server-pushed notification
if self.on_notification:
self.on_notification(msg)
async def call(self, method: str, params=None):
req_id = self.next_id
self.next_id += 1
future = asyncio.get_event_loop().create_future()
self.pending[req_id] = future
# Omit params if None to stay spec-compliant
payload = {"jsonrpc": "2.0", "method": method, "id": req_id}
if params is not None:
payload["params"] = params
await self.ws.send(json.dumps(payload))
return await future
# --- Usage ---
async def main():
client = WebSocketRpcClient("wss://ethereum.publicnode.com")
client.on_notification = lambda m: print(f"New Head: {m['params']['result']['hash']}")
await client.connect()
block = await client.call("eth_blockNumber")
print(f"Current block: {int(block, 16)}")
await client.call("eth_subscribe", ["newHeads"])
await asyncio.Future() # Keep running
if __name__ == "__main__":
asyncio.run(main())
When a message arrives with an id that matches something in the pending map/dict, it's a response. When a message arrives with no id, it's a server-pushed notification. This two-path dispatch is the core of every WebSocket JSON-RPC client.
The Python version uses websockets and asyncio.Future for the pending-request map — the same pattern as the JS Promise, just with async/await syntax. The _listen coroutine runs as a background task, dispatching incoming messages the same way the JS onmessage handler does.
Don't rely on ordering
The spec does not guarantee response ordering. Even if you send requests 1, 2, 3 in sequence, responses may arrive as 3, 1, 2. The id is the only reliable correlation mechanism. Always use it. Never assume arrival order matches send order.
Browser Limitation: No Custom Headers
The standard browser WebSocket API does not support custom headers. You cannot send Authorization: Bearer token in the handshake.
Standard workarounds:
- Query Params:
ws://api.com?token=xyz(careful with logs!) - Subprotocol:
new WebSocket(url, ["access_token", "xyz"]) - Auth Message: Send an
auth.loginJSON-RPC request as the very first message after connecting.
2.3 Multiplexing advantage
With HTTP, sending 3 requests means 3 round-trips (or 3 concurrent connections). With WebSocket, you fire all 3 requests into the same connection simultaneously. The total time is limited by the slowest response, not the sum of all three. For a batch of read-only blockchain queries, this can cut total latency by over half.
This is different from JSON-RPC batch requests (wrapping multiple calls in a JSON array). Batching also reduces round-trips, but it has a different trade-off: the server must process the entire batch before responding to any of it. With WebSocket multiplexing, each request gets its own independent response as soon as it's ready.
3. Beyond HTTP and WebSocket
Two other transports matter for specific architectures. Neither is common in web development, but both appear in major JSON-RPC deployments.
3.1 stdio — process-to-process
Standard input/output is the transport used when a parent process communicates with a child process running on the same machine. There's no network involved — just two pipes connecting the processes.
Two major protocols use this: the Language Server Protocol (LSP), which powers code intelligence in VSCode, Vim, Neovim, and Emacs; and the Model Context Protocol (MCP), which connects AI agents to local tools.
In both cases, the architecture is the same: the editor (or AI host) spawns a child process (the language server or MCP tool server) and communicates via JSON-RPC messages on stdin/stdout. The advantage is zero network overhead and no port allocation — you just read and write bytes to a pipe.
The challenge is framing. Over HTTP, the Content-Length header and the HTTP response boundary tell you where one message ends and the next begins. Over WebSocket, each message is a discrete frame. But stdin/stdout is a raw byte stream with no built-in message boundaries. Two JSON-RPC messages written back-to-back look like one continuous blob.
The solution depends on the protocol:
- LSP uses HTTP-style headers (
Content-Length: 123\r\n\r\n) before each message. This is robust and binary-safe. - MCP uses Newline Delimited JSON (NDJSON). Each message is a single line terminated by
\n. This is simpler to implement but requires that the JSON body never contains a literal newline character (they must be escaped as\n).
Here is the LSP header-based framing example:
Content-Length: 73\r\n
\r\n
{"jsonrpc":"2.0","method":"textDocument/didChange","params":{...}}
The receiver reads the Content-Length header, then reads exactly that many bytes to get the full JSON message. Simple, reliable, and reusable.
3.2 IPC and Unix domain sockets — same-machine, zero network
When a JSON-RPC client and server are on the same physical machine, they can skip the network stack entirely by communicating through a Unix domain socket (a file-based socket like /tmp/geth.ipc).
When you run an Ethereum node locally (Geth, Besu, Erigon), the node exposes an IPC endpoint that local scripts and tools connect to. The latency reduction is significant — benchmarks show significantly lower latency compared to TCP loopback (localhost:8545) because the kernel skips the entire IP stack (no headers, no routing, no port lookup).
Unless you're running blockchain infrastructure or building a process-to-process communication system, you won't use these transports directly. But understanding that they exist explains why JSON-RPC's transport agnosticism matters — it's not an academic property, it's a practical requirement used in production.
4. Choosing Your Transport
A simple decision guide:
Start with HTTP if you're building a standard API endpoint. It works with every HTTP client, load balancer, and monitoring tool. Your infrastructure already knows how to handle it. The overhead of new connections per request is acceptable for most workloads.
Switch to WebSocket when you need at least one of: server-to-client push (subscriptions, live updates), high-frequency calls where connection setup latency matters, or request multiplexing (many in-flight requests on one connection). The trade-off is complexity — you need connection management, reconnection logic, and the id-based correlation dispatch.
Use stdio/IPC when the client and server are on the same machine and you control both processes. Language servers, local MCP tools, blockchain node inter-layer communication. You get minimal latency and no port allocation, but you need to handle the framing protocol.
These aren't mutually exclusive. Many systems expose the same JSON-RPC methods over multiple transports: Ethereum nodes typically offer HTTP (:8545), WebSocket (:8546), and IPC (geth.ipc) simultaneously. The same method registry, the same validation logic, the same response construction — only the transport plumbing differs. That's the transport-agnostic promise in practice.