JSON-RPC 2.0: Protocol Guide, Request/Response Format, Ethereum & Implementation

Last updated:

JSON-RPC 2.0 is a stateless, transport-agnostic remote procedure call protocol encoded in JSON — a request object has exactly four fields: {"jsonrpc": "2.0", "method": "add", "params": [1, 2], "id": 1}, and the server responds with {"jsonrpc": "2.0", "result": 3, "id": 1}. Omitting id makes the request a notification — the server executes the method but sends no response. Batch requests bundle multiple calls into a JSON array; the server responds with an array of results in any order. Error responses use a standardized code/message structure: -32700 (Parse error), -32601 (Method not found), -32602 (Invalid params). Ethereum exposes its entire API as JSON-RPC — eth_getBalance, eth_blockNumber, eth_sendRawTransaction — making JSON-RPC fluency essential for Web3 development. This guide covers the full JSON-RPC 2.0 spec, batch requests, error handling, Ethereum JSON-RPC, WebSocket JSON-RPC, and a Node.js server implementation.

JSON-RPC 2.0 Request and Response Format

The JSON-RPC 2.0 specification defines two message types: the request object sent by the client, and the response object returned by the server. Both are plain JSON objects with a small, fixed set of fields. The jsonrpc field is always the string "2.0" and signals that the message conforms to the 2.0 spec (as opposed to the older, incompatible 1.0 spec). The method field is a string naming the remote procedure to call. The params field is either a JSON array (positional arguments) or a JSON object (named arguments) — it may be omitted if the method takes no parameters. The id field is a string, number, or null chosen by the client to correlate the response to this request.

// ── Request object: 4 fields ────────────────────────────────────
{
  "jsonrpc": "2.0",          // always exactly "2.0"
  "method":  "math.add",    // name of the remote procedure
  "params":  [1, 2],        // positional args (array) or named args (object)
  "id":      1              // client-chosen correlation ID
}

// params as named arguments (object) — equivalent to positional
{
  "jsonrpc": "2.0",
  "method":  "math.add",
  "params":  { "a": 1, "b": 2 },
  "id":      "req-abc"   // IDs can be strings too
}

// ── Success response object ──────────────────────────────────────
{
  "jsonrpc": "2.0",
  "result":  3,          // the return value of the method (any JSON type)
  "id":      1           // same id as the request
}

// ── Error response object ────────────────────────────────────────
{
  "jsonrpc": "2.0",
  "error": {
    "code":    -32601,             // integer error code
    "message": "Method not found", // short human-readable description
    "data":    { "method": "math.multiply" }  // optional extra details
  },
  "id": 1   // same id as the request (or null if id could not be determined)
}

// ── Rules ────────────────────────────────────────────────────────
// - Response MUST contain either "result" OR "error", never both
// - "result" field MUST be present on success (even if the value is null)
// - "id" in the response MUST match the request id
// - If a parse error occurs before the id is read, id is null in the error response

The id field is the correlation mechanism — without it, the client cannot match a response to its originating request. IDs can be any JSON value except objects and arrays: integers, strings, and null are all valid. In practice, auto-incrementing integers or UUIDs are most common. The server's response always echoes back the same id value. If the server cannot determine the id (e.g., the JSON could not be parsed at all), it returns a response with "id": null.

Notifications: Requests Without a Response

A notification is a JSON-RPC request object that omits the id field. Omitting id signals to the server that the client does not expect any response — not even an error response if the method fails. This fire-and-forget pattern is useful for logging, metrics, cache invalidation, and event broadcasting where confirmation is unnecessary. The server must not send any response for a notification, even if an error occurs.

// ── Notification: no "id" field ─────────────────────────────────
{
  "jsonrpc": "2.0",
  "method":  "logger.info",
  "params":  {
    "message": "User signed in",
    "userId":  "user-42",
    "ts":      1716854400
  }
  // no "id" field — server executes but sends NO response
}

// ── Sending a notification with fetch (Node.js / browser) ────────
async function notify(method: string, params: unknown): Promise<void> {
  await fetch('https://api.example.com/rpc', {
    method:  'POST',
    headers: { 'Content-Type': 'application/json' },
    body:    JSON.stringify({ jsonrpc: '2.0', method, params }),
    // no "id" field
  })
  // We do NOT await or check the response body — there is none
}

// ── Server: detecting notifications ──────────────────────────────
function isNotification(req: JsonRpcRequest): boolean {
  // id is absent OR explicitly null (though omission is preferred by spec)
  return req.id === undefined
}

// ── Common notification use cases ────────────────────────────────
// 1. Structured logging (fire-and-forget)
notify('log.write', { level: 'warn', msg: 'Disk usage > 90%' })

// 2. Analytics event tracking
notify('analytics.event', { event: 'button_click', component: 'signup' })

// 3. Cache invalidation broadcast
notify('cache.invalidate', { keys: ['user:42', 'session:abc'] })

// 4. Server-push over WebSocket (server is the sender)
// Server sends to all subscribed clients:
ws.send(JSON.stringify({
  jsonrpc: '2.0',
  method:  'subscription.update',
  params:  { topic: 'price.ETH', value: 3421.55 }
  // no "id" — server is pushing, not responding to a request
}))

Over WebSocket, notifications become the server's push mechanism. When a client calls eth_subscribe on an Ethereum node, the node responds once to confirm the subscription, then continuously pushes eth_subscription notification objects to the client as new blocks arrive or contract events fire. The Language Server Protocol uses the same pattern: the editor sends the server a textDocument/didChange notification when the user types; the server later pushes textDocument/publishDiagnostics notifications with lint errors. In both cases, there is no request-response pair — just one-way information flow encoded in the JSON-RPC notification format.

Batch Requests: Multiple Calls in One HTTP Round-Trip

JSON-RPC 2.0 batch requests allow a client to send multiple calls — any mix of requests and notifications — in a single HTTP POST body by wrapping them in a JSON array. The server processes all entries (potentially in parallel) and returns a JSON array of response objects. Only entries with an id field produce response objects; notifications are executed silently. Responses may arrive in any order — the id field is the only correlation mechanism. This is particularly powerful for reducing latency when multiple independent operations are needed.

// ── Batch request: JSON array of request objects ─────────────────
[
  { "jsonrpc": "2.0", "method": "math.add",      "params": [1, 2],    "id": 1 },
  { "jsonrpc": "2.0", "method": "math.subtract", "params": [10, 3],   "id": 2 },
  { "jsonrpc": "2.0", "method": "math.multiply", "params": [4, 5],    "id": 3 },
  { "jsonrpc": "2.0", "method": "log.write",     "params": ["batch!"]          } // notification
]

// ── Batch response: array in any order, notifications omitted ─────
[
  { "jsonrpc": "2.0", "result": 7,  "id": 2 },  // subtract responded first
  { "jsonrpc": "2.0", "result": 3,  "id": 1 },
  { "jsonrpc": "2.0", "result": 20, "id": 3 }
  // no entry for the notification — server executes it silently
]

// ── Client-side batch builder (TypeScript) ────────────────────────
interface JsonRpcRequest {
  jsonrpc: '2.0'
  method:  string
  params?: unknown
  id?:     string | number
}

interface JsonRpcResponse<T = unknown> {
  jsonrpc: '2.0'
  result?: T
  error?:  { code: number; message: string; data?: unknown }
  id:      string | number | null
}

let _nextId = 1

async function batchRpc(
  endpoint: string,
  calls: Array<{ method: string; params?: unknown }>
): Promise<Map<number, JsonRpcResponse>> {
  const requests: JsonRpcRequest[] = calls.map(call => ({
    jsonrpc: '2.0',
    method:  call.method,
    params:  call.params,
    id:      _nextId++,
  }))

  const res  = await fetch(endpoint, {
    method:  'POST',
    headers: { 'Content-Type': 'application/json' },
    body:    JSON.stringify(requests),
  })
  const responses: JsonRpcResponse[] = await res.json()

  // Map responses by id for O(1) lookup
  const byId = new Map<number, JsonRpcResponse>()
  for (const r of responses) {
    if (r.id !== null) byId.set(r.id as number, r)
  }
  return byId
}

// ── Usage ─────────────────────────────────────────────────────────
const results = await batchRpc('https://api.example.com/rpc', [
  { method: 'user.get',     params: { id: 42 } },
  { method: 'user.orders',  params: { userId: 42, limit: 10 } },
  { method: 'product.list', params: { category: 'electronics' } },
])
// 3 backend calls → 1 HTTP round-trip

Batching has a meaningful latency impact. Three sequential HTTP requests at 30 ms each take 90 ms; batched into one request, the total is approximately 35 ms — the time of a single round-trip plus parallel server execution. The trade-off is error handling: a batch may contain partial failures. Each response object must be checked independently for an error field — there is no single HTTP status code that conveys batch-level success or failure. The server should return HTTP 200 for a batch response even if some individual calls failed.

Error Codes and Error Response Format

JSON-RPC 2.0 defines a structured error response format and a set of reserved error codes. When a method call fails, the response object contains an error member instead of result. The error object has three fields: code (a signed integer), message (a human-readable string), and an optional data field for additional context. The specification reserves the integer range -32768 to -32000 for pre-defined and implementation errors.

// ── Standard pre-defined error codes ─────────────────────────────
// -32700  Parse error       — invalid JSON received (could not parse body)
// -32600  Invalid Request   — JSON is valid but not a valid JSON-RPC object
// -32601  Method not found  — the named method does not exist
// -32602  Invalid params    — method found but params are wrong type/value
// -32603  Internal error    — internal server error during method execution
// -32000 to -32099          — server-defined application errors (reserved range)

// ── Error response examples ───────────────────────────────────────
// Parse error (-32700): received "{ bad json"
{
  "jsonrpc": "2.0",
  "error": { "code": -32700, "message": "Parse error" },
  "id": null  // id is null because body could not be parsed
}

// Method not found (-32601)
{
  "jsonrpc": "2.0",
  "error": { "code": -32601, "message": "Method not found" },
  "id": 1
}

// Invalid params (-32602) with extra data field
{
  "jsonrpc": "2.0",
  "error": {
    "code":    -32602,
    "message": "Invalid params",
    "data": {
      "param":   "amount",
      "reason":  "must be a positive number",
      "received": -5
    }
  },
  "id": 2
}

// ── Application-defined server error (-32000 to -32099) ──────────
// Use these for domain errors: auth, rate limiting, business rules
{
  "jsonrpc": "2.0",
  "error": {
    "code":    -32000,        // your chosen code in the -32000 to -32099 range
    "message": "Unauthorized",
    "data": { "reason": "API key expired" }
  },
  "id": 3
}

// ── TypeScript error handler helper ──────────────────────────────
const JsonRpcErrorCode = {
  PARSE_ERROR:      -32700,
  INVALID_REQUEST:  -32600,
  METHOD_NOT_FOUND: -32601,
  INVALID_PARAMS:   -32602,
  INTERNAL_ERROR:   -32603,
  // Application-defined (expand as needed)
  UNAUTHORIZED:     -32000,
  RATE_LIMITED:     -32001,
  NOT_FOUND:        -32002,
} as const

function makeError(code: number, message: string, data?: unknown) {
  return {
    jsonrpc: '2.0' as const,
    error: { code, message, ...(data !== undefined ? { data } : {}) },
    id: null as string | number | null,
  }
}

The data field in the error object is unstructured by the spec — implementers can put any JSON value there. In practice it is used for machine-readable details that clients can act on: which param was invalid, which field failed validation, or a correlation ID for support lookup. The key design principle is to return -32602 (Invalid params) for input validation errors rather than mapping them to HTTP 422 — the JSON-RPC error code system is self-contained and transport-agnostic. When building a REST-like JSON-RPC API, define a consistent mapping between your application error types and codes in the -32000 to -32099 range and document them.

Ethereum JSON-RPC: The Most Widely Used Implementation

Ethereum chose JSON-RPC 2.0 as the interface for all node operations, making it the most widely deployed JSON-RPC implementation in the world. Every Ethereum execution client — Geth, Nethermind, Besu, Erigon — exposes the same JSON-RPC API on HTTP port 8545 and WebSocket port 8546. Node providers like Infura, Alchemy, and QuickNode are JSON-RPC proxies that relay calls to their backend node clusters. All Ethereum libraries (ethers.js, viem, web3.js) are JSON-RPC clients under the hood.

// ── Core Ethereum JSON-RPC methods ───────────────────────────────
// eth_blockNumber — get latest block number (hex-encoded integer)
POST https://mainnet.infura.io/v3/YOUR_API_KEY
{
  "jsonrpc": "2.0",
  "method":  "eth_blockNumber",
  "params":  [],
  "id":      1
}
// Response:
{ "jsonrpc": "2.0", "result": "0x13d5c9b", "id": 1 }
// 0x13d5c9b = 20831387 (block number)

// eth_getBalance — get ETH balance for an address (in wei, hex-encoded)
{
  "jsonrpc": "2.0",
  "method":  "eth_getBalance",
  "params":  ["0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "latest"],
  "id":      2
}
// Response:
{ "jsonrpc": "2.0", "result": "0x1b1ae4d6e2ef500000", "id": 2 }
// 0x1b1ae4d6e2ef500000 = 500000000000000000000 wei = 500 ETH

// eth_call — read-only smart contract call (no gas spent)
{
  "jsonrpc": "2.0",
  "method":  "eth_call",
  "params": [
    {
      "to":   "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC contract
      "data": "0x70a08231000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
      // ABI-encoded: balanceOf(address)
    },
    "latest"
  ],
  "id": 3
}

// eth_sendRawTransaction — submit a signed transaction
{
  "jsonrpc": "2.0",
  "method":  "eth_sendRawTransaction",
  "params":  ["0xf86c...signedHex"],
  "id":      4
}
// Response: transaction hash
{ "jsonrpc": "2.0", "result": "0x5d2cec...txhash", "id": 4 }

// ── Using with ethers.js (JSON-RPC under the hood) ────────────────
import { ethers } from 'ethers'

// ethers wraps JSON-RPC calls transparently
const provider = new ethers.JsonRpcProvider('https://mainnet.infura.io/v3/KEY')
const balance  = await provider.getBalance('0xd8dA6BF...')
// ^ internally calls eth_getBalance via JSON-RPC POST

// ── WebSocket subscription (server pushes new blocks) ─────────────
// Subscribe request:
{
  "jsonrpc": "2.0",
  "method":  "eth_subscribe",
  "params":  ["newHeads"],  // subscribe to new block headers
  "id":      1
}
// Subscribe response:
{ "jsonrpc": "2.0", "result": "0x1a2b3c", "id": 1 }
// "0x1a2b3c" is the subscription ID

// Server pushes notification for each new block (no id — it's a notification):
{
  "jsonrpc": "2.0",
  "method":  "eth_subscription",
  "params": {
    "subscription": "0x1a2b3c",
    "result": {
      "number":    "0x13d5c9c",
      "hash":      "0xabc...",
      "timestamp": "0x683f4e00"
    }
  }
}

One Ethereum-specific pattern worth noting: all numeric values in Ethereum JSON-RPC are hex-encoded strings (e.g., "0x13d5c9b" for block numbers, "0x1b1ae4d6e2ef500000" for wei amounts). This avoids JSON number precision issues with large integers — ETH balances can exceed JavaScript's Number.MAX_SAFE_INTEGER. Libraries like ethers.js and viem handle this automatically, converting hex strings to BigInt values transparently. Authentication to managed node providers is handled via API keys in the URL path or an Authorization header — not in the JSON-RPC payload itself.

JSON-RPC over WebSocket for Bidirectional Communication

While JSON-RPC works over any transport, WebSocket is its most powerful deployment: a persistent connection eliminates per-request TCP and TLS overhead, reducing latency from 20–50 ms (HTTP) to 2–5 ms. More importantly, WebSocket is full-duplex — the server can push notification objects to the client at any time using the same JSON-RPC message format. This makes JSON-RPC over WebSocket a natural fit for real-time feeds, subscriptions, and RPC-style protocols like LSP.

// ── JSON-RPC WebSocket client (Node.js with 'ws') ─────────────────
import WebSocket from 'ws'

class JsonRpcWsClient {
  private ws:      WebSocket
  private pending: Map<number, { resolve: Function; reject: Function }>
  private nextId = 1

  constructor(url: string) {
    this.ws      = new WebSocket(url)
    this.pending = new Map()

    this.ws.on('message', (data: Buffer) => {
      const msg = JSON.parse(data.toString())

      if (msg.id !== undefined && msg.id !== null) {
        // Response to a request — resolve the matching pending promise
        const deferred = this.pending.get(msg.id)
        if (!deferred) return
        this.pending.delete(msg.id)

        if (msg.error) {
          deferred.reject(new Error(`[${msg.error.code}] ${msg.error.message}`))
        } else {
          deferred.resolve(msg.result)
        }
      } else {
        // Server-push notification — emit as event
        this.emit(msg.method, msg.params)
      }
    })
  }

  call<T>(method: string, params?: unknown): Promise<T> {
    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 }))
    })
  }

  notify(method: string, params?: unknown): void {
    this.ws.send(JSON.stringify({ jsonrpc: '2.0', method, params }))
    // No id — server will not respond
  }

  // Simple event emitter for server-push notifications
  private handlers = new Map<string, Function[]>()
  on(method: string, handler: Function) {
    const list = this.handlers.get(method) ?? []
    list.push(handler)
    this.handlers.set(method, list)
  }
  private emit(method: string, params: unknown) {
    for (const fn of this.handlers.get(method) ?? []) fn(params)
  }
}

// ── Usage ─────────────────────────────────────────────────────────
const client = new JsonRpcWsClient('wss://api.example.com/rpc')

// Wait for connection, then make RPC calls
client.ws.on('open', async () => {
  // Regular request-response
  const result = await client.call<number>('math.add', [10, 20])
  console.log(result) // 30

  // Subscribe to price updates (server will push notifications)
  await client.call('subscribe', { topic: 'price.BTC' })

  // Handle server-push notifications
  client.on('price.update', (params: { topic: string; price: number }) => {
    console.log(`${params.topic}: $${params.price}`)
  })

  // Fire-and-forget notification (no response)
  client.notify('analytics.pageview', { page: '/dashboard' })
})

The key engineering consideration for WebSocket JSON-RPC is in-flight request management. Because multiple requests can be outstanding simultaneously on a single WebSocket connection, the client must maintain a Map from id to a pending Promise resolver. Each incoming message is dispatched: messages with a matching id resolve or reject the corresponding promise; messages without an id are server-push notifications dispatched to event handlers. Timeout handling is important — if a server never responds to a request, the pending Map grows unbounded. A production implementation should set a timeout per request (typically 30 seconds) and reject the promise if no response arrives.

Implementing a JSON-RPC 2.0 Server in Node.js

A minimal JSON-RPC 2.0 server requires only one HTTP endpoint that parses the request body, validates the JSON-RPC envelope, dispatches to method handlers, and formats the response. The core logic is straightforward: handle single requests and arrays (batch), validate required fields, look up handlers by method name, call handlers, and catch errors into the appropriate error code. The following implementation handles everything the spec requires in approximately 80 lines with no external dependencies, plus a more production-ready version using Express.

// ── Minimal JSON-RPC 2.0 server (Node.js built-ins only) ─────────
import http from 'http'

type Handler = (params: unknown) => unknown | Promise<unknown>

const methods: Record<string, Handler> = {
  'math.add':      ([a, b]: number[]) => a + b,
  'math.subtract': ([a, b]: number[]) => a - b,
  'user.get':      async ({ id }: { id: number }) => {
    // Simulate DB lookup
    if (id === 42) return { id: 42, name: 'Alice', email: 'alice@example.com' }
    throw Object.assign(new Error('User not found'), { code: -32002 })
  },
}

function makeError(id: unknown, code: number, message: string, data?: unknown) {
  return { jsonrpc: '2.0', error: { code, message, ...(data ? { data } : {}) }, id: id ?? null }
}

async function handleRequest(req: unknown) {
  // Validate JSON-RPC envelope
  if (typeof req !== 'object' || req === null || Array.isArray(req)) {
    return makeError(null, -32600, 'Invalid Request')
  }
  const { jsonrpc, method, params, id } = req as Record<string, unknown>

  if (jsonrpc !== '2.0' || typeof method !== 'string') {
    return makeError(id, -32600, 'Invalid Request')
  }

  // Look up method handler
  const handler = methods[method]
  if (!handler) {
    if (id === undefined) return null  // notification — no response for missing methods
    return makeError(id, -32601, 'Method not found')
  }

  // Call handler
  try {
    const result = await handler(params)
    if (id === undefined) return null  // notification — execute but don't respond
    return { jsonrpc: '2.0', result: result ?? null, id }
  } catch (err: unknown) {
    if (id === undefined) return null  // notification error — silent
    const e = err as { code?: number; message?: string; data?: unknown }
    const code    = e.code    ?? -32603
    const message = e.message ?? 'Internal error'
    return makeError(id, code, message, e.data)
  }
}

const server = http.createServer(async (req, res) => {
  if (req.method !== 'POST') {
    res.writeHead(405).end('Method Not Allowed')
    return
  }

  // Read body
  const chunks: Buffer[] = []
  for await (const chunk of req) chunks.push(chunk)
  const body = Buffer.concat(chunks).toString()

  let parsed: unknown
  try {
    parsed = JSON.parse(body)
  } catch {
    const response = makeError(null, -32700, 'Parse error')
    res.writeHead(200, { 'Content-Type': 'application/json' })
    res.end(JSON.stringify(response))
    return
  }

  let response: unknown
  if (Array.isArray(parsed)) {
    // Batch request
    if (parsed.length === 0) {
      response = makeError(null, -32600, 'Invalid Request — empty batch')
    } else {
      const results = await Promise.all(parsed.map(handleRequest))
      // Filter out nulls (notifications produce no response)
      const filtered = results.filter(r => r !== null)
      response = filtered.length > 0 ? filtered : null
    }
  } else {
    response = await handleRequest(parsed)
  }

  res.writeHead(200, { 'Content-Type': 'application/json' })
  if (response === null) {
    res.end()  // all-notification batch or single notification
  } else {
    res.end(JSON.stringify(response))
  }
})

server.listen(3000, () => console.log('JSON-RPC server on http://localhost:3000'))

// ── Express version with authentication middleware ─────────────────
import express, { Request, Response } from 'express'
const app = express()
app.use(express.json({ limit: '1mb' }))

// Auth middleware: check API key header
app.use('/rpc', (req: Request, res: Response, next) => {
  if (req.headers['x-api-key'] !== process.env.API_KEY) {
    // JSON-RPC error format even for auth failures
    res.json(makeError(null, -32000, 'Unauthorized'))
    return
  }
  next()
})

app.post('/rpc', async (req: Request, res: Response) => {
  const body = req.body
  let response: unknown

  if (Array.isArray(body)) {
    const results = await Promise.all(body.map(handleRequest))
    const filtered = results.filter(r => r !== null)
    response = filtered.length > 0 ? filtered : null
  } else {
    response = await handleRequest(body)
  }

  if (response === null) {
    res.status(204).end()
  } else {
    res.json(response)
  }
})

app.listen(3001)

Authentication in JSON-RPC is always handled outside the protocol itself. Common approaches: API keys in the X-API-Key header (easiest for server-to-server), Bearer JWT in the Authorization header (for user auth), or an API key passed in the params object of every request (for simple implementations where HTTP headers are not available). For WebSocket connections, authenticate the WebSocket handshake (HTTP Upgrade request) with a token in the query string or header — once the connection is established, all JSON-RPC calls on that connection inherit the authenticated identity. Never put secrets in JSON-RPC method names or log the full request body without redacting credential fields.

Key Terms

JSON-RPC 2.0
A stateless remote procedure call protocol that uses JSON for encoding. Defined in a community specification published in 2010, it superseded the incompatible JSON-RPC 1.0. The 2.0 spec introduced named parameters (object form of params), batch requests, notifications, and a standardized error object format. JSON-RPC 2.0 is transport-agnostic — it works over HTTP, HTTPS, WebSocket, TCP, stdio, and any other message-passing channel. The "jsonrpc": "2.0" field in every message signals spec compliance and is mandatory.
notification
A JSON-RPC request object that omits the id field. The server executes the named method when it receives a notification but sends no response — not even an error response if the method fails. Notifications are fire-and-forget: the client has no way to know whether execution succeeded. They are used for logging, analytics, cache invalidation, and server-push over WebSocket where confirmation is unnecessary. In a batch, notification entries produce no response objects; the batch response array contains only entries for requests that had an id.
batch request
A JSON-RPC request body that is a JSON array of request objects instead of a single object. The server processes all entries (potentially in parallel) and returns a JSON array of response objects. Responses may be in any order — clients must match them to originating requests by id. Notifications in a batch produce no response entries. If all entries are notifications, the server returns nothing (no body). Batch requests are the primary mechanism for reducing HTTP round-trips when multiple independent operations are needed, and are used heavily in Ethereum block explorers and Language Server Protocol implementations.
method
The method field in a JSON-RPC request is a string naming the remote procedure to invoke. Method names beginning with rpc. are reserved by the specification for internal use. Application methods typically use dot notation for namespacing ("math.add", "user.get") or camelCase ("getUser"). The server maintains a registry mapping method name strings to handler functions. If the named method is not found in the registry, the server returns error code -32601 (Method not found).
Ethereum JSON-RPC
The standardized JSON-RPC 2.0 API exposed by all Ethereum execution clients (Geth, Nethermind, Besu, Erigon). Documented in EIP-1474 and the Ethereum JSON-RPC Specification, it defines methods prefixed with eth_, net_, web3_, and debug_. All numeric values (block numbers, Wei amounts, gas prices) are hex-encoded strings to avoid JavaScript integer precision issues. The API is available over HTTP (port 8545) for request-response calls and WebSocket (port 8546) for subscriptions via eth_subscribe. Node providers (Infura, Alchemy, QuickNode) proxy this API at scale.
Language Server Protocol (LSP)
Microsoft's protocol for communication between code editors and language servers, defined in 2016 and now used by VS Code, Neovim, Emacs, and over 100 other editors. LSP uses JSON-RPC 2.0 as its message encoding format, transported over stdio or TCP. Requests with id expect responses (e.g., textDocument/completion); notifications without id are fire-and-forget (e.g., textDocument/didChange). The server pushes textDocument/publishDiagnostics notifications to report errors without the editor polling. LSP is the largest non-blockchain deployment of JSON-RPC 2.0, with implementations for every major programming language.

FAQ

What is JSON-RPC 2.0?

JSON-RPC 2.0 is a stateless, transport-agnostic remote procedure call (RPC) protocol that uses JSON for encoding. Published as a specification in 2010, it defines a lightweight mechanism for calling named methods on a remote server and receiving results. A JSON-RPC 2.0 request is a JSON object with exactly 4 fields: jsonrpc (always the string "2.0"), method (the procedure name to invoke), params (an array or object of arguments), and id (a client-generated identifier correlating the request to its response). The server returns a response object with jsonrpc, result (on success), or error (on failure), and the same id. Omitting id makes the call a notification — the server executes the method but sends no response. The protocol is transport-agnostic and works over HTTP, HTTPS, WebSocket, TCP, stdin/stdout, and named pipes. JSON-RPC 2.0 is the foundation of the Language Server Protocol used in VS Code and all modern code editors, and of the Ethereum JSON-RPC API used by every Ethereum node.

What is the difference between JSON-RPC and REST?

JSON-RPC and REST are both JSON-over-HTTP API styles, but with fundamentally different design philosophies. REST is resource-oriented: URLs identify resources (/users/42), HTTP verbs express actions (GET, POST, PUT, DELETE), and HTTP status codes convey outcomes (200, 404, 422). JSON-RPC is action-oriented: a single endpoint (typically POST /rpc) receives all calls, the method field names the operation ("user.getById", "user.update"), and the protocol has its own error code system (-32700 to -32099). REST maps naturally to CRUD over resources with cacheable GET requests; JSON-RPC maps naturally to procedure calls, batch operations, and bidirectional WebSocket communication. Batch requests are a built-in JSON-RPC feature — send an array of 50 operations and get an array of 50 results in one HTTP round-trip — whereas REST requires either multiple requests or a custom batching endpoint. Choose REST for public APIs with HTTP caching needs; choose JSON-RPC for internal RPC services, real-time WebSocket APIs, or when batch efficiency matters.

How do JSON-RPC batch requests work?

A JSON-RPC 2.0 batch request is a JSON array of request objects sent in a single HTTP POST body. For example: [{"jsonrpc":"2.0","method":"add","params":[1,2],"id":1},{"jsonrpc":"2.0","method":"subtract","params":[5,3],"id":2}]. The server processes all requests — potentially in parallel — and responds with a JSON array of response objects in any order. The id field is essential for matching responses to requests since order is not guaranteed. If a batch contains notifications (requests without id), no response is generated for those items — only non-notification requests get response entries. If all requests in a batch are notifications, the server returns nothing. Batch requests reduce network overhead dramatically: 50 RPC calls over separate HTTP connections might take 500 ms; batched into one request they complete in 15–30 ms. The trade-off is that partial failure handling requires inspecting each response object individually rather than relying on a single HTTP status code.

What are the standard JSON-RPC error codes?

JSON-RPC 2.0 defines 5 standard pre-defined error codes in the range -32700 to -32603. Code -32700 (Parse error) means the server received invalid JSON that could not be parsed. Code -32600 (Invalid Request) means the JSON was valid but the object did not conform to the JSON-RPC 2.0 specification. Code -32601 (Method not found) means the method named in the request does not exist on the server. Code -32602 (Invalid params) means the method exists but the params argument was invalid — wrong type, missing required argument, or extra arguments. Code -32603 (Internal error) means a server-side error occurred during method execution. The range -32000 to -32099 is reserved for implementation-defined server errors — use these for application-level errors like authentication failure, rate limiting, or business rule violations. Codes -32768 to -32000 are reserved by the specification for future use. Each error response object has three fields: code (integer), message (short human-readable string), and an optional data field for additional details.

How does Ethereum use JSON-RPC?

Ethereum exposes its entire node API as a JSON-RPC 2.0 interface over HTTP (port 8545) and WebSocket (port 8546). Every Ethereum client — Geth, Nethermind, Besu, Erigon — implements the same Ethereum JSON-RPC specification, making it possible to switch node providers (Infura, Alchemy, QuickNode) by changing a URL. The most commonly used methods include eth_getBalance (get ETH balance for an address), eth_blockNumber (latest block number), eth_call (execute a read-only smart contract call), eth_sendRawTransaction (submit a signed transaction), and eth_getLogs (query contract event logs with filter criteria). All Ethereum wallets and libraries (ethers.js, viem, web3.js) are JSON-RPC clients under the hood. The eth_subscribe method over WebSocket enables real-time subscriptions to new blocks, pending transactions, and contract events — the server pushes JSON-RPC notification objects to the client without polling. As of 2024, Ethereum processes over 1 million JSON-RPC API calls per day across major node providers.

What is a JSON-RPC notification?

A JSON-RPC notification is a request object that omits the id field entirely. For example: {"jsonrpc":"2.0","method":"log","params":{"level":"info","message":"User signed in"}}. When the server receives a notification, it executes the method but sends no response — not even an error response if the method fails. This makes notifications a fire-and-forget mechanism: the client cannot know whether the call succeeded. Notifications are useful for logging, analytics, cache invalidation signals, and event broadcasts where the client has no interest in confirmation. Over WebSocket, both client and server can send notifications. When a client calls eth_subscribe on an Ethereum node, the node responds once to confirm the subscription, then continuously pushes eth_subscription notification objects as new blocks arrive. In a batch request, notification entries produce no response objects — only requests with an id field get response entries.

How do I implement a JSON-RPC server in Node.js?

A JSON-RPC 2.0 server in Node.js requires a single POST endpoint that parses the request body, dispatches the method call to a handler function, and formats the response. The core logic has 4 steps: (1) Parse the JSON body — if parsing fails, return error -32700. (2) Validate the JSON-RPC envelope — check jsonrpc === "2.0" and method is a string; if not, return -32600. (3) Look up the method in a handler registry — if not found, return -32601. (4) Call the handler with params, catch errors, and return the result or -32603/-32602 as appropriate. For batch requests, detect an array body and process each entry independently, collecting results into a response array (excluding notifications). The jayson npm package (280k weekly downloads) provides a production-ready JSON-RPC 2.0 server and client for Node.js with TypeScript support, middleware, and batch handling built in. For minimal implementations, the logic fits in approximately 60 lines of plain Node.js with no dependencies.

Can JSON-RPC work over WebSocket?

Yes — JSON-RPC over WebSocket enables full bidirectional RPC: the client can call server methods, and the server can push notification objects to the client using the same JSON-RPC message format. Over a persistent WebSocket connection, there is no per-request HTTP overhead, making round-trip latency 2–5 ms compared to 20–50 ms for HTTP. The same JSON-RPC 2.0 object format is used — requests with id await a response, notifications without id are fire-and-forget. Multiple in-flight requests can be outstanding simultaneously; the id field correlates responses to their originating requests. The ws npm package (120M weekly downloads) is commonly used to implement JSON-RPC over WebSocket. The Language Server Protocol uses JSON-RPC over a stdio pipe or TCP socket with the same semantics — the server pushes textDocument/publishDiagnostics notifications to the editor without being asked. The Ethereum eth_subscribe method sends eth_subscription push notifications over WebSocket when subscribed to newHeads or logs events.

Further reading and primary sources