JSON-RPC 2.0 defines a strict correlation contract: a request carrying an id MUST receive exactly one response with the same id, while a request without an id is a Notification and MUST NOT receive any response. Batch responses may be returned in any order, so a client that assumes response order matches request order will silently mis-associate results. The correct pattern is to assign unique ids, keep an id-to-promise map, and match responses by id rather than position. This article explains the contract, the id: null edge case, notification traps, Ethereum subscription semantics, and a runnable Node.js client you can test against any endpoint.
The JSON-RPC 2.0 id Correlation Contract
The JSON-RPC 2.0 specification defines a request as an object with jsonrpc, method, params, and an optional id. Section 4 states that if id is included, the server MUST reply with the same value in the response. Section 5 states that a request without an id is a Notification and the server MUST NOT reply at all. This is the entire correlation contract: the id is the only field that ties a response back to its request.
Because the id is the sole correlation key, it must be unique among in-flight requests on a given client. The specification does not require ids to be integers, but it recommends that clients use integers and avoid fractional parts. In practice, a monotonically increasing integer counter is the simplest way to guarantee uniqueness without coordination.
The authoritative source for these rules is the JSON-RPC 2.0 specification, which is the primary reference for protocol semantics. The Ethereum JSON-RPC specification at ethereum.org layers method names and parameter shapes on top of that base contract without changing the id rules.
- Request with id: exactly one response, same id.
- Request without id: Notification, zero responses.
- Batch: an array of requests; responses may be in any order.
- Invalid batch: a single error object, not an array.
Why Naive Clients Mismatch Responses
The highest-impact bug in hand-rolled batching is assuming that the Nth response belongs to the Nth request. Section 6 of the specification explicitly permits the server to return batch responses in any order, and real servers exploit that freedom because they process requests concurrently. A client that zips responses to requests by index will attach the wrong result to the wrong promise, often without throwing an error.
The failure is silent because JSON-RPC responses are structurally identical regardless of which request they answer. If you send eth_blockNumber and eth_chainId in one batch and the server returns them swapped, your code will happily resolve the block-number promise with a chain id string. Type checks may catch it, but many methods return overlapping types such as hex strings, so the corruption can propagate.
The fix is to treat the id as the correlation key and never rely on array position. This is the same discipline described in JSON-RPC batching best practices, which covers when to batch; this article covers how to match what comes back.
The id-to-Promise Map Pattern
The correct client pattern is a map from id to a pending promise resolver. When you send a request, you allocate the next id, store the resolver under that id, and write the request to the socket or batch. When a response arrives, you look up the resolver by response.id, resolve it, and delete the entry. Order never enters the logic.
This pattern also gives you a natural place to enforce timeouts and to detect responses with unknown ids, which usually indicate a server bug or a response from a previous connection that was not cleaned up. Keeping the map scoped to a single connection prevents cross-talk when you reconnect.
For retries, reuse the same id for the retried request rather than inventing a new one. Reusing the id preserves idempotency tracking and lets the server deduplicate; the tradeoffs are covered in JSON-RPC idempotency and duplicate requests.
// Minimal id-to-promise correlation client (Node.js 18+)
const pending = new Map();
let nextId = 1;
function send(ws, method, params) {
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
});
}
function onMessage(raw) {
const msg = JSON.parse(raw);
const entry = pending.get(msg.id);
if (!entry) {
console.warn('response with unknown id', msg.id);
return;
}
pending.delete(msg.id);
if (msg.error) entry.reject(new Error(JSON.stringify(msg.error)));
else entry.resolve(msg.result);
}Notifications: No id, No Response
A request without an id is a Notification, and the specification forbids the server from replying. This is useful for fire-and-forget writes such as sending a log line to a sink or submitting a best-effort telemetry event where you do not need confirmation. The client must not allocate a promise for a notification, because no response will ever arrive to resolve it.
The common trap is expecting a confirmation that the spec prohibits. If you send a notification and then wait for a response, your queue will hang until a timeout fires, and you may misattribute a later response to the wrong request. The rule is simple: if you need a result, include an id; if you do not, omit it and do not wait.
A second trap is mixing notifications and requests in the same batch and then assuming the response array has the same length as the request array. It will not, because notifications produce no entries. Count only the requests that carried an id when validating the response array.
The id: null Edge Case
Section 5 of the specification reserves id: null for responses to requests whose id could not be detected, such as a parse error or an invalid request. In those cases the server cannot echo an id because it never successfully read one, so it returns null. This is a protocol-level signal, not an application value.
You must not use null as a normal application id. If your client sends id: null, you cannot distinguish a legitimate response from an error response generated because the server failed to parse your request. Reserve null for the server and use positive integers on the client.
A worked example: if you send a malformed JSON body, the server returns a single object with id: null and an error code of -32700 (Parse error). Your client should treat any response with id: null as a protocol error, not as the answer to a request you sent.
// Server response to a malformed request body
{
"jsonrpc": "2.0",
"id": null,
"error": { "code": -32700, "message": "Parse error" }
}Ethereum eth_subscribe and Subscription Notifications
Ethereum's eth_subscribe flow is different from a normal request/response pair. The subscribe call itself is a normal request with an id and receives a normal response containing a subscription id string. After that, the server pushes eth_subscription notifications that carry the subscription id inside params.subscription, not as a top-level request id.
This means your correlation logic needs two layers. The first layer matches the subscribe response by request id. The second layer routes incoming eth_subscription messages by params.subscription to the handler registered for that subscription. Treating the subscription id as a request id will not work because these are notifications, not responses.
The distinction matters when you combine subscriptions with ordinary calls on one socket. The comparison in eth_subscribe logs vs WebSocket polling explains when the push model is worth the extra routing layer.
// Subscribe response (normal id correlation)
{ "jsonrpc": "2.0", "id": 1, "result": "0x9cef478923ff08bf67fde6c64013158d" }
// Pushed notification (route by params.subscription, not id)
{
"jsonrpc": "2.0",
"method": "eth_subscription",
"params": {
"subscription": "0x9cef478923ff08bf67fde6c64013158d",
"result": { "number": "0x10d4f" }
}
}Runnable Batch Example with Out-of-Order Re-association
The example below sends a three-element batch with ids 1, 2, and 3, then deliberately processes a shuffled response array to prove that correlation is by id, not by position. It prints each request alongside its matched response so you can see the association explicitly.
Run it against any JSON-RPC endpoint that supports HTTP POST. Replace the URL with your own endpoint; the RPC endpoints guide explains how to obtain one. The code uses only the Node.js standard library.
// node batch-correlate.mjs
const URL = process.env.RPC_URL || 'https://your-endpoint.example';
const requests = [
{ jsonrpc: '2.0', id: 1, method: 'eth_blockNumber', params: [] },
{ jsonrpc: '2.0', id: 2, method: 'eth_chainId', params: [] },
{ jsonrpc: '2.0', id: 3, method: 'net_version', params: [] }
];
const res = await fetch(URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(requests)
});
const responses = await res.json();
// Simulate a server that returns responses out of order.
const shuffled = [...responses].reverse();
const byId = new Map(requests.map(r => [r.id, r]));
for (const resp of shuffled) {
const req = byId.get(resp.id);
console.log('request', req.method, 'id', req.id, '->', JSON.stringify(resp.result ?? resp.error));
}Building a Correlation Table Against Your Own Endpoint
To produce reproducible evidence that your endpoint honors the id contract, run the batch example above and record each request/response pair in a table. Re-run it at will; the table is your own measurement, not a vendor claim. This separates documented protocol behavior from observed provider behavior, which may vary.
Fill in one row per request. The matched column should be yes only when response id equals request id. The latency column is wall-clock time from send to receive for that id. The error column captures any JSON-RPC error object. If any row shows matched = no, your client or the endpoint is violating the contract.
- Request id: the integer you assigned.
- Method: the JSON-RPC method name.
- Response id: the id echoed by the server.
- Matched: yes if response id equals request id.
- Latency ms: measured send-to-receive time.
- Error: any error object returned, or none.
Connection Multiplexing, HTTP/2, and Retries
When you multiplex many calls over one connection, responses can genuinely arrive out of order. HTTP/2 interleaves streams on a single TCP connection, and WebSocket frames arrive in the order the server writes them, which need not match the order you sent requests. The id map handles both cases without special logic.
For retries, reuse the id of the original request. Inventing a new id per retry breaks idempotency tracking because the server sees two distinct requests. The details of safe retry behavior are in JSON-RPC idempotency and duplicate requests.
Connection reuse and keep-alive settings affect how many in-flight requests share a socket, which in turn affects how much ordering freedom the server has. See RPC connection reuse and HTTP/2 keep-alive for the transport side of this picture.
Troubleshooting Checklist for id and Ordering Failures
Use this checklist when responses appear mismatched or missing. Each item maps to a specific clause of the specification, so you can decide whether the fault is in your client or in the endpoint.
If a response carries an id you never sent, treat it as a protocol error and log it. If one batch element has no response, check whether you accidentally omitted its id, making it a notification. If a notification appears to hang a queue, you are waiting for a response the spec forbids. If you sent duplicate ids in one batch, the specification treats that as a client error, so fix the id allocator.
- Response with unexpected id: log and discard; check for stale connection state.
- Missing response for one batch element: verify the request carried an id.
- Notification hanging a queue: remove the promise; notifications never resolve.
- Duplicate ids in one batch: client error; enforce a unique counter.
- Single error object instead of array: the batch itself was invalid.
Limitations and Tradeoffs
The specification does not cap batch size, so a very large batch may be rejected by a provider for reasons outside the protocol. It also does not mandate a single response per batch when the batch itself is invalid: the server returns one error object, not an array. Your client must handle both the array shape and the single-object shape.
Provider behavior varies. Some endpoints may return responses in request order as an implementation detail, but you must not depend on it because the spec permits any order. Documented behavior is that order is not guaranteed; anything stricter is provider-specific and may change.
Finally, correlation by id does not solve application-level idempotency or ordering of side effects. Two requests with different ids may still be applied in an order you did not intend. For write methods, combine id correlation with the idempotency guidance linked above.
Next Steps for Production Clients
Start by replacing any index-based response handling with an id map, then add the correlation table test to your CI so regressions are caught. Once correlation is solid, tune batch sizes and connection reuse using the guidance in JSON-RPC batching best practices.
If you are choosing an endpoint, review RPC pricing and the API service overview, and browse the OnFinality Learn hub for related integration topics. For Ethereum-specific methods, see the Ethereum network page.
Keep the JSON-RPC 2.0 specification open as your primary reference. When a provider's behavior diverges from it, treat the divergence as documented-but-variable and test it yourself with the table method above.