Logo
New RPC users get 35% off their first monthView the offer
OnFinality Learn
Integration & Development14 min read

JSON-RPC Over HTTP, WebSocket, and IPC: Choosing a Transport

Learn how JSON-RPC 2.0 semantics change across HTTP, WebSocket, and IPC transports, and how to choose and combine them for production blockchain applications.

TL;DR

JSON-RPC 2.0 is transport-agnostic: the same request/response/notification envelopes travel over HTTP, WebSocket, or IPC, but each transport imposes different connection semantics, ordering guarantees, and failure modes. HTTP is request-per-message and relies on batching for throughput; WebSocket is a single multiplexed connection that enables subscriptions and notifications but requires id correlation and resubscribe logic; IPC is ideal for node operators but unsuitable for applications that must survive node restarts. This article explains the layered model, provides a decision table, a runnable Node.js example, and a verification checklist to evaluate providers.

The Layered Model: JSON-RPC Semantics vs Transport Behavior

The most common source of confusion when integrating blockchain RPC is conflating the JSON-RPC layer with the transport layer. The JSON-RPC 2.0 specification defines a transport-agnostic protocol: it deals in request objects, response objects, and notifications, each with a jsonrpc member, an id for correlation, and a method/params or result/error payload. The transport layer—HTTP, WebSocket, or IPC—deals with connection establishment, message framing, ordering, backpressure, and, in the case of HTTP, status codes.

This separation matters because error handling lives in two different places. A JSON-RPC error is a structured object with a numeric code (e.g., -32601 for Method not found) and a message, returned inside a valid JSON-RPC response. An HTTP status code like 429, 502, or 504 is a transport-level signal that the request never reached a JSON-RPC handler or that the connection failed. A 429 will never carry a -32603, and a -32603 will never carry a transport status. When you see a 200 OK with an error object inside, that is the JSON-RPC layer speaking; when you see a 502 with an HTML body, that is the transport layer failing before JSON-RPC semantics apply.

The Ethereum JSON-RPC specification builds on this by defining execution-layer methods and their expected parameters and return types, but it does not redefine transport semantics. Providers may document additional behavior—such as batch limits or WebSocket idle timeouts—but those are implementation details, not protocol requirements. Always treat provider-specific behavior as documented/varies by provider and verify against your own measurements.

  • JSON-RPC layer: request/response/notification envelopes, numeric error codes, id correlation.
  • Transport layer: connection lifecycle, framing, ordering, HTTP status codes, backpressure.
  • A 200 OK with an error object is a JSON-RPC error; a 502 with no JSON body is a transport failure.
  • Provider-specific limits (batch size, idle timeout) are not part of the JSON-RPC 2.0 spec.

HTTP as a Request-per-Message Transport and the Role of Batching

HTTP is a request-per-message transport: each JSON-RPC request typically corresponds to one HTTP POST. The protocol does not mandate connection reuse, though HTTP/1.1 keep-alive and HTTP/2 multiplexing can reduce TCP handshake overhead. The big lever for throughput over HTTP is not connection reuse but batching—sending an array of request objects in a single HTTP request, as defined in Section 6 of the JSON-RPC 2.0 specification.

Batching has important nuances. A batch is atomic on the wire—all requests arrive together—but not in outcome: the server may process them in any order, and some may succeed while others fail. The response is an array of response objects, and the client must correlate by id. An empty array is an Invalid Request per the spec. Servers may impose a maximum batch size; when exceeded, they typically return an implementation-defined error code in the -32000 to -32099 range (reserved for implementation-defined server errors) rather than a spec-defined code like -32600. Always check your provider's documentation for batch limits and the exact error code returned.

For high-volume indexing or analytics workloads, batching can dramatically reduce HTTP overhead. However, it also increases the blast radius of a single failed request: if the HTTP request fails at the transport level, the entire batch is lost. Design your client to retry individual requests or the whole batch with idempotency in mind.

  • HTTP is request-per-message; batching is the primary throughput lever.
  • Batch responses are arrays; correlate by id because order is not guaranteed.
  • Empty batch array is an Invalid Request per JSON-RPC 2.0 Section 6.
  • Batch size limits are implementation-defined; expect -32000 range codes when exceeded.

WebSocket as a Multiplexed Transport: Subscriptions and Notifications

WebSocket provides a single, persistent, full-duplex connection. This changes the JSON-RPC contract in two fundamental ways. First, responses can arrive out of order relative to requests because multiple requests can be in flight simultaneously on the same socket. Id correlation becomes mandatory—you cannot assume the first response matches the first request. Second, WebSocket enables server-initiated messages: notifications, which are JSON-RPC requests without an id (Section 4.1 of the JSON-RPC 2.0 specification), and subscription pushes.

In Ethereum's execution-layer JSON-RPC, eth_subscribe is a method that returns a normal response containing a subscription id. After that, the node pushes eth_subscription notifications. Critically, the correlation key for these notifications lives in params.subscription, not in the request id. A client that only tracks request ids will not be able to route subscription events correctly. This is a common integration bug.

The persistent nature of WebSocket also means that a dropped socket silently loses every in-flight request and every active subscription. There is no automatic replay. Production clients must implement resubscribe-and-backfill logic: detect the disconnect, re-establish the connection, re-issue eth_subscribe calls, and fetch any missed blocks or logs using HTTP or WebSocket requests to fill the gap. This is part of the transport design, not an error handler.

  • Responses arrive out of order; id correlation is mandatory.
  • eth_subscribe returns a subscription id in a normal response; events arrive as eth_subscription notifications with correlation in params.subscription.
  • A dropped socket loses all in-flight requests and subscriptions; resubscribe-and-backfill is required.
  • Notifications have no id and must not be replied to.

IPC and In-Process Transports: Node Operator vs Application Tradeoffs

IPC (inter-process communication) transports, such as Unix domain sockets or Windows named pipes, are local to the machine. They offer low overhead and no network stack, making them ideal for node operators who run a full node and want to query it from a co-located process. However, they are the wrong choice for an application that must survive a node restart or scale across hosts. IPC has no TLS termination, no cross-host failover, and typically allows only one connection per client. If your application runs in a container or on a different machine, IPC is not an option.

For self-hosted nodes, exposing IPC to a shared backend can be tempting for performance, but it couples your application's availability to a single node process. If that node restarts, all IPC clients lose their connection and must reconnect. There is no built-in load balancing or failover. For production applications that require high availability, HTTP or WebSocket endpoints—often provided by a managed service—are more appropriate. If you are evaluating providers, see the RPC endpoints guide (RPC Assistant) for criteria.

In-process transports (e.g., calling the node's RPC handler directly within the same process) are even more tightly coupled and are generally used only for testing or embedded scenarios. They bypass network serialization but inherit the same single-point-of-failure characteristics.

  • IPC is local-only, no TLS, no cross-host failover, typically one connection per client.
  • Suitable for node operators querying a co-located node; unsuitable for distributed applications.
  • Self-hosted IPC exposure couples application availability to a single node process.
  • For high availability, prefer HTTP or WebSocket endpoints from a managed provider.

Transport Uniformity of Blockchain JSON-RPC Methods

Not all JSON-RPC methods are available over all transports. The common blockchain method set—state queries like eth_getBalance, history queries like eth_getBlockByNumber, and transaction submission like eth_sendRawTransaction—are ordinary request/response calls and work over HTTP, WebSocket, and IPC alike. However, subscription methods such as eth_subscribe and eth_unsubscribe exist only over stateful transports: WebSocket and IPC. They are not available over HTTP because HTTP is request-per-message and cannot support server-initiated pushes.

This means that a client which fails over from WebSocket to HTTP for a subscription must change its strategy, not just its endpoint. It cannot simply re-issue eth_subscribe over HTTP; it must switch to polling—for example, using eth_getLogs with a moving block range—or use a different provider that supports WebSocket. The eth_subscribe logs vs WebSocket polling article covers this tradeoff in detail.

Similarly, some admin or debug methods may be restricted to IPC for security reasons, even if they are technically request/response. Always verify which methods are exposed on each transport for your provider.

  • State and history methods work over HTTP, WebSocket, and IPC.
  • Subscription methods (eth_subscribe, eth_unsubscribe) require a stateful transport (WebSocket or IPC).
  • Failing over from WebSocket to HTTP for subscriptions requires switching to polling, not just changing the URL.
  • Admin/debug methods may be IPC-only for security.

Decision Table: Mapping Workload Shape to Transport

Choosing a transport is a function of workload shape. The table below maps common patterns to recommended transports and the reasons behind each choice. Use it as a starting point, then validate against your own latency and reliability measurements.

For single-shot reads (e.g., fetching a balance before displaying it), HTTP is simple and sufficient. For high-volume batch indexing (e.g., backfilling logs for an analytics pipeline), HTTP with batching is often the most efficient. For event streaming (e.g., real-time transaction monitoring), WebSocket with eth_subscribe is the natural fit. For transaction submission, both HTTP and WebSocket work, but HTTP may be preferred for its simplicity and idempotent retry semantics. For admin/debug introspection, IPC is often the only option on self-hosted nodes.

  • Single-shot read: HTTP — simple, stateless, easy to retry.
  • High-volume batch indexing: HTTP with batching — reduces per-request overhead.
  • Event streaming: WebSocket — supports eth_subscribe and server push.
  • Transaction submission: HTTP or WebSocket — HTTP for simplicity, WebSocket for lower latency if already connected.
  • Admin/debug introspection: IPC — often required for privileged methods.

Runnable Example: Same Request Over HTTP and WebSocket

The following Node.js script issues the same eth_blockNumber request over HTTP and WebSocket, prints the transport, the round-trip time measured on your machine, and the raw id echoed by each response. This makes the correlation contract visible rather than assumed. Run it against your own endpoint to compare behavior. Note that the WebSocket example uses the ws package; install it with npm install ws if needed.

The script measures round-trip time using process.hrtime.bigint() for high resolution. The id is set to a unique value for each request so you can see it echoed back. For WebSocket, the response may arrive after other messages if multiple requests are in flight; this example sends one request at a time for clarity.

const http = require('http');
const WebSocket = require('ws');

const HTTP_URL = 'https://ethereum.publicnode.com';
const WS_URL = 'wss://ethereum.publicnode.com';

function httpRequest(url, payload) {
  return new Promise((resolve, reject) => {
    const start = process.hrtime.bigint();
    const req = http.request(url, { method: 'POST', headers: { 'Content-Type': 'application/json' } }, (res) => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => {
        const end = process.hrtime.bigint();
        const rttMs = Number(end - start) / 1e6;
        resolve({ transport: 'HTTP', rttMs, response: JSON.parse(data) });
      });
    });
    req.on('error', reject);
    req.write(JSON.stringify(payload));
    req.end();
  });
}

function wsRequest(url, payload) {
  return new Promise((resolve, reject) => {
    const ws = new WebSocket(url);
    const start = process.hrtime.bigint();
    ws.on('open', () => ws.send(JSON.stringify(payload)));
    ws.on('message', (data) => {
      const end = process.hrtime.bigint();
      const rttMs = Number(end - start) / 1e6;
      ws.close();
      resolve({ transport: 'WebSocket', rttMs, response: JSON.parse(data) });
    });
    ws.on('error', reject);
  });
}

(async () => {
  const payload = { jsonrpc: '2.0', method: 'eth_blockNumber', params: [], id: 1 };
  const httpResult = await httpRequest(HTTP_URL, payload);
  const wsResult = await wsRequest(WS_URL, payload);
  console.log('HTTP:', httpResult.transport, 'RTT:', httpResult.rttMs.toFixed(2), 'ms', 'id:', httpResult.response.id, 'result:', httpResult.response.result);
  console.log('WebSocket:', wsResult.transport, 'RTT:', wsResult.rttMs.toFixed(2), 'ms', 'id:', wsResult.response.id, 'result:', wsResult.response.result);
})();

Operational Limitations and Tradeoffs to Record Before Standardising

Before standardising on a single transport, document the operational limitations that affect reliability and performance. Proxies and load balancers often terminate idle connections; a WebSocket connection that appears healthy may be silently dropped after a period of inactivity. Per-connection state makes naive round-robin failover unsafe for subscriptions: if you have multiple WebSocket connections behind a load balancer, a subscription created on one connection will not receive events on another. You must either use sticky sessions or implement a subscription manager that tracks which connection holds which subscription.

A provider's HTTP and WebSocket endpoints may not be served by the same fleet. This means measured latency and available namespaces can differ between them. For example, an HTTP endpoint might be served by a read-replica fleet optimized for queries, while the WebSocket endpoint is served by a different set of nodes with subscription support. Always test both endpoints independently. For guidance on monitoring, see Monitoring RPC endpoints.

Finally, consider the cost and complexity of maintaining multiple transports. While combining HTTP for queries and WebSocket for subscriptions is common, it doubles the surface area for configuration, authentication, and monitoring. Weigh the benefits against the operational overhead.

  • Idle connection termination by proxies/load balancers can silently drop WebSocket connections.
  • Round-robin failover is unsafe for subscriptions; use sticky sessions or a subscription manager.
  • HTTP and WebSocket endpoints may be served by different fleets; latency and namespaces can differ.
  • Maintaining multiple transports increases configuration and monitoring complexity.

Troubleshooting Transport-Specific Failures

When a request fails, first determine which layer is reporting the error. If you receive an HTTP status code like 429, 502, or 504, the failure is at the transport or gateway layer; the JSON-RPC request may not have been processed. Check your provider's status page and retry with exponential backoff. If you receive a 200 OK with a JSON-RPC error object, the request reached the handler but was rejected—inspect the error.code and error.message for details.

For WebSocket, common issues include silent disconnects, missed subscription events, and id correlation bugs. Implement a heartbeat (ping/pong) to detect dead connections. Ensure your client tracks subscription ids from eth_subscribe responses and routes eth_subscription notifications by params.subscription. If you see duplicate or missing events, check whether your resubscribe logic is backfilling correctly. The JSON-RPC id correlation and batch ordering article provides deeper guidance.

For HTTP batching, if you receive an error code in the -32000 range, you may have exceeded the provider's batch limit. Reduce the batch size or split into multiple requests. If an empty array is sent, expect an Invalid Request error. Always validate your batch payloads against the JSON-RPC 2.0 specification.

  • HTTP 429/502/504: transport/gateway failure; retry with backoff.
  • 200 OK with JSON-RPC error: handler-level rejection; inspect error.code.
  • WebSocket: implement heartbeat, track subscription ids, backfill on reconnect.
  • Batch errors in -32000 range: likely exceeded batch limit; reduce batch size.

Verification Checklist for Evaluating RPC Providers

Use this checklist to evaluate every provider you consider. It focuses on transport capabilities and limits that affect production applications. Record your findings in a results table for comparison.

Run these tests against both HTTP and WebSocket endpoints. For batch support, send a batch of increasing size until you hit an error; note the maximum size and the error code. For subscriptions, attempt eth_subscribe with newHeads or logs and verify you receive notifications. For idle timeout, open a WebSocket connection and wait without sending messages; note when it closes. For namespace documentation, check the provider's docs for which methods are available on each transport.

  • Does HTTP support batch? What is the batch cap and which code is returned when exceeded?
  • Does the WebSocket endpoint support eth_subscribe for the methods you need?
  • What is the idle timeout observed from your own network?
  • Does the provider document both endpoints' namespaces?
  • Measure round-trip time for a simple eth_blockNumber over both transports from your deployment region.
| Item / Step | Input Variant | Observed Code | Observed Message | HTTP Status | Elapsed ms |
| --- | --- | --- | --- | --- | --- |
| eth_blockNumber over HTTP | ____ | ____ | ____ | ____ | ____ |
| eth_blockNumber over WebSocket | ____ | ____ | ____ | ____ | ____ |
| Batch size cap test | ____ | ____ | ____ | ____ | ____ |
| eth_subscribe newHeads | ____ | ____ | ____ | ____ | ____ |

Next Steps: Combining Transports for Production

A robust production architecture often combines transports: HTTP for stateless queries and batching, WebSocket for subscriptions and low-latency pushes. Use a connection manager to handle reconnects, resubscribe, and backfill. For high availability, consider multiple providers and failover logic that respects transport differences. If you are building on Ethereum, start with the Ethereum network page and explore the OnFinality Learn hub for more integration guides.

When selecting a provider, review RPC pricing and API service to understand limits and support. For connection reuse and keep-alive strategies, see RPC connection reuse and HTTP/2 keep-alive. Always test with your own workloads and measure against your own endpoints.

Finally, keep the JSON-RPC 2.0 specification and the Ethereum JSON-RPC specification as authoritative references. Provider documentation is useful but may vary; the protocol semantics are stable.

  • Combine HTTP for queries and WebSocket for subscriptions.
  • Implement reconnect, resubscribe, and backfill logic.
  • Test multiple providers and measure latency from your region.
  • Refer to the JSON-RPC 2.0 spec and Ethereum JSON-RPC spec for authoritative semantics.

Never Worry about Infrastructure Again

OnFinality takes away the heavy lifting of DevOps so you can build smarter and faster.

Get Started