Master JSON-RPC: the simple RPC protocol built on JSON. Learn when REST's resource model breaks down, how JSON-RPC enables action-oriented APIs, and why Ethereum, VS Code, and Bitcoin chose this protocol.
In
Part 1
, we explored JSON’s origins. In
Part 2
, we added validation. In
Part 3
and
Part 4
, we optimized performance with binary formats.
Now we examine JSON as a protocol layer - not just data format, but a communication standard for distributed systems.
What XML Had: SOAP and XML-RPC (1999-2003)
XML’s approach: Comprehensive protocol stack with SOAP envelopes, WSDL service definitions, WS-* extensions for security/reliability/transactions, and automatic code generation from schemas.
1
2
3
4
5
6
7
8
9
10
11
| <!-- SOAP: Full protocol infrastructure -->
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Header>
<wsse:Security>...</wsse:Security>
</soap:Header>
<soap:Body>
<tns:GetUser xmlns:tns="http://example.com/users">
<tns:UserId>123</tns:UserId>
</tns:GetUser>
</soap:Body>
</soap:Envelope>
|
Benefit: Complete protocol definition, automatic tooling, enterprise features
Cost: Massive complexity, heavyweight infrastructure, steep learning curve
JSON’s approach: Lightweight protocol conventions (JSON-RPC) - optional structure
Architecture shift: Heavyweight protocol → Lightweight convention, Built-in tooling → Simple libraries, Enterprise features → Essential simplicity
REST dominates web APIs, but its resource-oriented model doesn’t fit every problem. How do you represent transfer_funds(from, to, amount) as HTTP verbs and URLs? You could force it into POST /transfers with a body, but you’re fighting the paradigm.
JSON-RPC solves this: It’s a simple protocol for calling remote functions over any transport (HTTP, WebSockets, Unix sockets). No mental gymnastics to fit actions into resource models.
This article covers the JSON-RPC 2.0 specification, implementation patterns, real-world usage (Ethereum, Language Server Protocol, Bitcoin), and when to choose RPC over REST.
Running Example: User API with JSON-RPC
In
Part 1
, we started with basic JSON users. In
Part 2
, we added validation. In
Part 3
, we stored them efficiently in JSONB.
Now JSON-RPC adds the protocol layer - structured remote function calls for our User API.
REST approach (resource-oriented):
1
2
3
4
| GET /users/user-5f9d88c # Get user
PUT /users/user-5f9d88c # Update user
POST /users/user-5f9d88c/follow # Follow action (forced into REST)
GET /users/search?q=alice # Search (not really RESTful)
|
JSON-RPC approach (action-oriented):
1
2
3
4
| {"jsonrpc": "2.0", "method": "getUserById", "params": {"id": "user-5f9d88c"}, "id": 1}
{"jsonrpc": "2.0", "method": "updateUser", "params": {"id": "user-5f9d88c", "name": "Alice Smith"}, "id": 2}
{"jsonrpc": "2.0", "method": "followUser", "params": {"followerId": "user-abc123", "followeeId": "user-5f9d88c"}, "id": 3}
{"jsonrpc": "2.0", "method": "searchUsers", "params": {"query": "alice", "filters": {"verified": true}}, "id": 4}
|
Why JSON-RPC fits user management:
followUser() is an action, not a resourcesearchUsers() with complex filtering is a function call- Batch requests: get user + followers + following in one call
- WebSocket support for real-time user status updates
This completes the protocol layer for our User API.
The RPC Problem
Functions Across the Network
Programming is full of function calls:
1
2
| // Local function
const result = calculator.add(5, 3); // 8
|
Distributed systems need the same concept:
1
2
| // Remote function (same interface)
const result = await remoteCalculator.add(5, 3); // 8
|
The challenge: How do you encode function calls for transmission over the network?
Why REST Doesn’t Always Fit
REST is resource-oriented. It models everything as CRUD operations on resources:
1
2
3
4
| GET /users/123 # Read
POST /users # Create
PUT /users/123 # Update
DELETE /users/123 # Delete
|
This works well for data-centric APIs. But what about:
- Actions:
transferFunds(from, to, amount) - Calculations:
calculateRoute(origin, destination) - Operations:
restartServer(serverId) - Queries:
searchUsers(query, filters, pagination)
You can force these into REST:
1
2
3
4
| POST /transfers
POST /route-calculations
POST /server-restarts
GET /users?search=query&filter=...
|
But you’re working against the model. The endpoints become verb-heavy, the resource abstraction breaks down, and you end up with a de facto RPC API pretending to be REST.
The REST Contortion Problem: Many real-world operations violate REST’s resource model and require awkward workarounds:
Batch operations: How do you “delete 100 users” RESTfully? DELETE /users?ids=1,2,3... breaks URI semantics.
Transactions: How do you express “transfer funds AND log transaction AND notify user” as atomic operation?
Complex queries: Search with 10 filters becomes /users?filter1=x&filter2=y&filter3=z... (URL length limits).
Multi-resource actions: “Archive project AND notify team AND update dashboard” spans multiple resources.
Stateful operations: “Start build → monitor progress → retrieve artifacts” doesn’t map to CRUD.
In JSON-RPC, these are just function calls:
batchDeleteUsers(ids: [1,2,3,...])transferFunds(from, to, amount, notify: true)searchUsers(filters: {...})archiveProject(projectId, options: {...})startBuild(params) → pollBuildStatus(buildId) → getArtifacts(buildId)
The paradigm matches the problem naturally.
The Cardinality Problem: REST naturally expresses “all or one” but struggles with “some”:
GET /users - all users (collection)GET /users/123 - one user (item)GET /users?ids=1,5,12 - some specific users (awkward, not RESTful)
Try that last one in a code review and your local architect will have opinions.
Common “RESTful” workarounds teams are forced into:
Option 1: POST with body (violates HTTP semantics)
1
2
| POST /users/batch-get
{"ids": [1, 5, 12]}
|
Now reads use POST. Not cacheable, not idempotent.
Option 2: Create temporary “selection” resources
1
2
| POST /user-selections → {"selection_id": "abc"}
GET /user-selections/abc/users
|
Two requests to get some users. Absurdly complex.
Option 3: Multiple single requests
1
2
3
| GET /users/1
GET /users/5
GET /users/12
|
3 round trips. Latency compounds.
Option 4: Switch to GraphQL
1
| query { user1: user(id: 1), user5: user(id: 5) }
|
You’ve abandoned REST entirely.
Option 5: Use query params anyway and endure the code review comments.
RPC treats all cardinalities equally as function parameters:
getAllUsers() - allgetUser(id: 123) - onegetUsers(ids: [1,5,12]) - some (no awkwardness, no arguments)searchUsers(query, filters) - filtered some
REST couples URLs to database structure. RPC describes operations:
REST URLs often mirror database tables:
/users → SELECT * FROM users
/posts → SELECT * FROM posts
/comments → SELECT * FROM comments
Problem: Database refactoring forces API changes. Split a table? Your URL structure breaks. Add a join table? Need new endpoints.
RPC methods hide implementation details:
1
2
3
| getUserProfile(id) // Queries: users + posts + followers (3 tables)
searchContent(query) // Hits: Elasticsearch (not database at all)
processOrder(orderId) // Touches: 5 microservices across 3 databases
|
Benefit: Refactor backend freely without breaking API contract. Add caching, change storage systems, split services - clients see the same method signature.
Key Insight: REST excels at resource manipulation (CRUD). RPC excels at action invocation (function calls). Choose based on your domain - don’t force actions into resource models or vice versa. If your API is mostly verbs (calculate, process, execute, transform), RPC is the natural fit.
The RPC Renaissance
RPC isn’t new - it dates to the 1980s (Sun RPC, CORBA). But modern RPC protocols learned from past failures:
Old RPC problems:
- Complex specifications (CORBA, SOAP)
- Tight coupling to programming languages
- Poor tooling
- Verbose XML payloads
Modern RPC solutions:
- Simple specifications (JSON-RPC 2.0 is 8 pages)
- Language-agnostic
- Excellent tooling
- Efficient formats (JSON, Protocol Buffers)
timeline
title Evolution of RPC Protocols
1984 : Sun RPC (ONC RPC)
: Binary protocol, C-centric
1991 : CORBA
: Complex, multi-language
1998 : XML-RPC
: Simple HTTP + XML
1999 : SOAP
: Enterprise standard, heavyweight
2005 : JSON-RPC 1.0
: Lightweight alternative
2010 : JSON-RPC 2.0
: Current specification
2015 : gRPC (Google)
: Protocol Buffers + HTTP/2
2020+ : Modern adoption
: Ethereum, LSP, Bitcoin use JSON-RPC
What is JSON-RPC?
JSON-RPC 2.0 is a stateless, light-weight remote procedure call protocol that uses JSON for
serialization
.
Specification:
jsonrpc.org/spec
Length: 8 pages
Release: 2010
Protocol vs Architectural Style: JSON-RPC is a protocol (concrete specification with exact message format). REST is an architectural style (design principles without exact specification). This is why:
- JSON-RPC compliance is objective: does your message have
jsonrpc, method, params, id? Yes or no. - REST compliance is subjective: is this “RESTful enough”? Depends who you ask.
- JSON-RPC has a version number (2.0). REST doesn’t (it’s conceptual).
- JSON-RPC debates are rare (spec is clear). REST debates are endless (principles are interpretable).
This distinction matters: protocols give clarity, architectural styles give flexibility. Choose based on whether you need strict interoperability (protocol) or design guidance (style).
Core Concepts
1. Transport-agnostic
- HTTP (most common)
- WebSockets (bidirectional)
- Unix sockets (local IPC)
- TCP sockets
- Message queues
- Any byte stream
2. Stateless
- Each request is independent
- No session management required
- Easy to load balance
3. Simple specification
- Three message types: request, response, notification
- Standard error codes
- Batch request support
4. Language-agnostic
- Works in any language with JSON support
- No code generation required
- Libraries available for all major languages
JSON-RPC Request
1
2
3
4
5
6
| {
"jsonrpc": "2.0",
"method": "subtract",
"params": [42, 23],
"id": 1
}
|
Fields:
jsonrpc - Protocol version (always “2.0”)method - Function name to callparams - Function arguments (array or object)id - Request identifier (for matching response)
JSON-RPC Response (Success)
1
2
3
4
5
| {
"jsonrpc": "2.0",
"result": 19,
"id": 1
}
|
Fields:
jsonrpc - Protocol versionresult - Return valueid - Matches request id
JSON-RPC Response (Error)
1
2
3
4
5
6
7
8
9
| {
"jsonrpc": "2.0",
"error": {
"code": -32601,
"message": "Method not found",
"data": "No method named 'subtract'"
},
"id": 1
}
|
Error object:
code - Numeric error codemessage - Human-readable descriptiondata - Optional additional information
JSON-RPC Notification
A request without id - fire-and-forget:
1
2
3
4
5
| {
"jsonrpc": "2.0",
"method": "logEvent",
"params": {"level": "info", "message": "User logged in"}
}
|
No response expected or sent. Useful for logging, metrics, non-critical updates.
sequenceDiagram
participant Client
participant Server
Note over Client,Server: Request/Response (with id)
Client->>Server: {"method": "add", "params": [5,3], "id": 1}
Server->>Client: {"result": 8, "id": 1}
Note over Client,Server: Notification (no id)
Client->>Server: {"method": "log", "params": {...}}
Note over Server: No response sent
Note over Client,Server: Error Response
Client->>Server: {"method": "unknown", "id": 2}
Server->>Client: {"error": {...}, "id": 2}
JSON-RPC supports two parameter styles:
Positional Parameters (Array)
1
2
3
4
5
6
| {
"jsonrpc": "2.0",
"method": "subtract",
"params": [42, 23],
"id": 1
}
|
Server receives parameters by position:
1
2
3
| function subtract(a, b) {
return a - b; // a=42, b=23
}
|
Use when:
- Function has few parameters (1-3)
- Parameter order is obvious
- Compatibility with older clients matters
Named Parameters (Object)
1
2
3
4
5
6
| {
"jsonrpc": "2.0",
"method": "subtract",
"params": {"minuend": 42, "subtrahend": 23},
"id": 1
}
|
Server receives parameters by name:
1
2
3
| function subtract({minuend, subtrahend}) {
return minuend - subtrahend;
}
|
Use when:
- Function has many parameters
- Parameter order isn’t obvious
- Optional parameters exist
- Self-documenting calls matter
Recommendation: Use named parameters for new APIs. They’re more maintainable and self-documenting.
Standard Error Codes
JSON-RPC defines standard error codes for common failures:
| Code | Message | Meaning |
|---|
| -32700 | Parse error | Invalid JSON received |
| -32600 | Invalid Request | JSON is not a valid request object |
| -32601 | Method not found | Method does not exist |
| -32602 | Invalid params | Invalid method parameters |
| -32603 | Internal error | Internal JSON-RPC error |
| -32000 to -32099 | Server error | Implementation-defined server errors |
Application-defined errors should use codes outside these ranges:
1
2
3
4
5
6
7
| const ErrorCodes = {
UNAUTHORIZED: -32001,
RATE_LIMIT_EXCEEDED: -32002,
RESOURCE_NOT_FOUND: -32003,
VALIDATION_FAILED: -32004,
INSUFFICIENT_FUNDS: -32005
};
|
Error Code Convention: Reserve -32000 to -32099 for server errors (infrastructure, not business logic). Use codes starting from -32100 or positive numbers for application-specific errors.
Batch Requests
Send multiple requests in one HTTP call:
Request:
1
2
3
4
5
6
| [
{"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"},
{"jsonrpc": "2.0", "method": "subtract", "params": [42,23], "id": "2"},
{"jsonrpc": "2.0", "method": "notify_hello", "params": [7]},
{"jsonrpc": "2.0", "method": "get_data", "id": "9"}
]
|
Response:
1
2
3
4
5
| [
{"jsonrpc": "2.0", "result": 7, "id": "1"},
{"jsonrpc": "2.0", "result": 19, "id": "2"},
{"jsonrpc": "2.0", "result": ["hello", 5], "id": "9"}
]
|
Note: Notification (notify_hello) has no response.
Benefits:
- Reduced HTTP overhead (3 calls → 1 request)
- Lower latency (1 round trip instead of 3)
- Atomic batches (all succeed or all fail)
- Better connection utilization
Use cases:
- Bulk operations (process 100 items)
- Dependent calls (fetch user, fetch orders for user)
- Dashboard aggregation (fetch multiple widgets)
Implementing a JSON-RPC Server
Node.js (Express)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
| const express = require('express');
const app = express();
app.use(express.json());
// Define methods
const methods = {
add: (a, b) => a + b,
subtract: (a, b) => a - b,
multiply: (a, b) => a * b,
divide: (a, b) => {
if (b === 0) {
const error = new Error('Division by zero');
error.code = -32000;
throw error;
}
return a / b;
},
// Named parameters
greet: ({name, title}) => {
return `Hello, ${title} ${name}!`;
}
};
// JSON-RPC handler
app.post('/rpc', (req, res) => {
const request = req.body;
// Validate request structure
if (!request.jsonrpc || request.jsonrpc !== '2.0') {
return res.json({
jsonrpc: '2.0',
error: {code: -32600, message: 'Invalid Request'},
id: request.id || null
});
}
// Handle batch requests
if (Array.isArray(request)) {
const responses = request
.map(req => handleSingleRequest(req))
.filter(resp => resp !== null); // Filter out notifications
return res.json(responses);
}
// Handle single request
const response = handleSingleRequest(request);
if (response) {
res.json(response);
} else {
res.status(204).end(); // Notification - no response
}
});
function handleSingleRequest(request) {
const {method, params, id} = request;
// Notification - no response
if (id === undefined) {
if (methods[method]) {
try {
methods[method](...(Array.isArray(params) ? params : [params]));
} catch (err) {
// Notifications don't return errors
}
}
return null;
}
// Check if method exists
if (!methods[method]) {
return {
jsonrpc: '2.0',
error: {code: -32601, message: 'Method not found'},
id
};
}
// Execute method
try {
let args;
if (Array.isArray(params)) {
args = params;
} else if (typeof params === 'object') {
args = [params]; // Named parameters
} else {
args = [];
}
const result = methods[method](...args);
return {
jsonrpc: '2.0',
result,
id
};
} catch (error) {
return {
jsonrpc: '2.0',
error: {
code: error.code || -32603,
message: error.message
},
id
};
}
}
app.listen(3000, () => {
console.log('JSON-RPC server on http://localhost:3000/rpc');
});
|
Go (net/rpc/jsonrpc)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
| package main
import (
"encoding/json"
"errors"
"fmt"
"net/http"
)
// MathService provides mathematical operations
type MathService struct{}
// Args for basic math operations
type Args struct {
A float64 `json:"a"`
B float64 `json:"b"`
}
// Add two numbers
func (s *MathService) Add(args Args) (float64, error) {
return args.A + args.B, nil
}
// Subtract two numbers
func (s *MathService) Subtract(args Args) (float64, error) {
return args.A - args.B, nil
}
// Divide two numbers
func (s *MathService) Divide(args Args) (float64, error) {
if args.B == 0 {
return 0, errors.New("division by zero")
}
return args.A / args.B, nil
}
// JSON-RPC request structure
type Request struct {
JsonRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
ID interface{} `json:"id"`
}
// JSON-RPC response structure
type Response struct {
JsonRPC string `json:"jsonrpc"`
Result interface{} `json:"result,omitempty"`
Error *Error `json:"error,omitempty"`
ID interface{} `json:"id"`
}
// Error structure
type Error struct {
Code int `json:"code"`
Message string `json:"message"`
}
var mathService = &MathService{}
func handleRPC(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req Request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, -32700, "Parse error", nil)
return
}
if req.JsonRPC != "2.0" {
writeError(w, -32600, "Invalid Request", req.ID)
return
}
// Dispatch to method
var result interface{}
var err error
switch req.Method {
case "add":
var args Args
json.Unmarshal(req.Params, &args)
result, err = mathService.Add(args)
case "subtract":
var args Args
json.Unmarshal(req.Params, &args)
result, err = mathService.Subtract(args)
case "divide":
var args Args
json.Unmarshal(req.Params, &args)
result, err = mathService.Divide(args)
default:
writeError(w, -32601, "Method not found", req.ID)
return
}
if err != nil {
writeError(w, -32000, err.Error(), req.ID)
return
}
resp := Response{
JsonRPC: "2.0",
Result: result,
ID: req.ID,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func writeError(w http.ResponseWriter, code int, message string, id interface{}) {
resp := Response{
JsonRPC: "2.0",
Error: &Error{Code: code, Message: message},
ID: id,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) // JSON-RPC errors use 200 status
json.NewEncoder(w).Encode(resp)
}
func main() {
http.HandleFunc("/rpc", handleRPC)
fmt.Println("JSON-RPC server on http://localhost:8080/rpc")
http.ListenAndServe(":8080", nil)
}
|
Python implementation available: See
example repository
for Flask-based JSON-RPC server with decorator pattern for method registration.
Implementation Checklist:
Implementing a JSON-RPC Client
JavaScript (Browser/Node.js)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
| class JSONRPCClient {
constructor(url, options = {}) {
this.url = url;
this.requestId = 0;
this.timeout = options.timeout || 30000;
this.headers = options.headers || {};
}
/**
* Call a remote method
* @param {string} method - Method name
* @param {Array|Object} params - Parameters
* @returns {Promise<any>} Result
*/
async call(method, params = []) {
const request = {
jsonrpc: '2.0',
method,
params,
id: ++this.requestId
};
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(this.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.headers
},
body: JSON.stringify(request),
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.error) {
const error = new Error(data.error.message);
error.code = data.error.code;
error.data = data.error.data;
throw error;
}
return data.result;
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error(`Request timeout after ${this.timeout}ms`);
}
throw error;
}
}
/**
* Send a notification (no response expected)
* @param {string} method - Method name
* @param {Array|Object} params - Parameters
*/
notify(method, params = []) {
const request = {
jsonrpc: '2.0',
method,
params
};
// Fire and forget
fetch(this.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.headers
},
body: JSON.stringify(request)
}).catch(() => {
// Ignore errors for notifications
});
}
/**
* Send batch request
* @param {Array<{method, params}>} calls - Array of calls
* @returns {Promise<Array>} Array of results
*/
async batch(calls) {
const requests = calls.map(call => ({
jsonrpc: '2.0',
method: call.method,
params: call.params || [],
id: ++this.requestId
}));
const response = await fetch(this.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.headers
},
body: JSON.stringify(requests)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Map responses back to call order
return requests.map(req => {
const resp = data.find(r => r.id === req.id);
if (!resp) {
throw new Error(`No response for request ${req.id}`);
}
if (resp.error) {
const error = new Error(resp.error.message);
error.code = resp.error.code;
throw error;
}
return resp.result;
});
}
}
// Usage examples
const client = new JSONRPCClient('http://localhost:3000/rpc', {
timeout: 5000,
headers: {
'Authorization': 'Bearer token123'
}
});
// Simple call
const sum = await client.call('add', [5, 3]);
console.log('Sum:', sum); // 8
// Named parameters
const greeting = await client.call('greet', {name: 'Alice', title: 'Dr.'});
console.log(greeting); // "Hello, Dr. Alice!"
// Notification
client.notify('logEvent', {level: 'info', message: 'User action'});
// Batch request
const results = await client.batch([
{method: 'add', params: [1, 2]},
{method: 'multiply', params: [3, 4]},
{method: 'subtract', params: [10, 5]}
]);
console.log(results); // [3, 12, 5]
// Error handling
try {
await client.call('divide', [10, 0]);
} catch (error) {
console.error(`Error ${error.code}: ${error.message}`);
}
|
Go Client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
| package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"sync/atomic"
)
type JSONRPCClient struct {
url string
client *http.Client
id int64
}
type Request struct {
JsonRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params interface{} `json:"params"`
ID int64 `json:"id,omitempty"`
}
type Response struct {
JsonRPC string `json:"jsonrpc"`
Result json.RawMessage `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
ID int64 `json:"id"`
}
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
func (e *RPCError) Error() string {
return fmt.Sprintf("JSON-RPC error %d: %s", e.Code, e.Message)
}
func NewClient(url string) *JSONRPCClient {
return &JSONRPCClient{
url: url,
client: &http.Client{},
}
}
func (c *JSONRPCClient) Call(method string, params interface{}, result interface{}) error {
id := atomic.AddInt64(&c.id, 1)
request := Request{
JsonRPC: "2.0",
Method: method,
Params: params,
ID: id,
}
body, err := json.Marshal(request)
if err != nil {
return err
}
resp, err := c.client.Post(c.url, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
}
var response Response
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
return err
}
if response.Error != nil {
return response.Error
}
if result != nil {
return json.Unmarshal(response.Result, result)
}
return nil
}
func (c *JSONRPCClient) Notify(method string, params interface{}) {
request := Request{
JsonRPC: "2.0",
Method: method,
Params: params,
}
body, _ := json.Marshal(request)
c.client.Post(c.url, "application/json", bytes.NewReader(body))
}
// Usage
func main() {
client := NewClient("http://localhost:8080/rpc")
// Call method
var sum float64
err := client.Call("add", map[string]float64{"a": 5, "b": 3}, &sum)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("Sum: %v\n", sum)
// Notification
client.Notify("logEvent", map[string]string{
"level": "info",
"message": "Application started",
})
}
|
Python client: Similar pattern using requests library. See
example repository
for complete implementation.
flowchart TB
subgraph client["Client Implementation"]
build[Build Request]
send[Send HTTP POST]
parse[Parse Response]
check{Error?}
end
subgraph server["Server Implementation"]
receive[Receive Request]
validate[Validate Structure]
dispatch[Dispatch to Method]
execute[Execute Method]
respond[Build Response]
end
build --> send
send --> receive
receive --> validate
validate --> dispatch
dispatch --> execute
execute --> respond
respond --> parse
parse --> check
check -->|Yes| error[Throw Error]
check -->|No| result[Return Result]
style client fill:#3A4A5C,stroke:#6b7280,color:#f0f0f0
style server fill:#3A4C43,stroke:#6b7280,color:#f0f0f0
JSON-RPC over WebSockets
HTTP is request/response only. WebSockets enable bidirectional RPC - servers can call client methods and vice versa.
Server (Node.js)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
| const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws) => {
console.log('Client connected');
// Handle incoming requests
ws.on('message', (data) => {
try {
const request = JSON.parse(data);
const response = handleRequest(request);
if (response) {
ws.send(JSON.stringify(response));
}
} catch (error) {
ws.send(JSON.stringify({
jsonrpc: '2.0',
error: {code: -32700, message: 'Parse error'},
id: null
}));
}
});
// Server can initiate requests to client
function notifyClient(event, data) {
ws.send(JSON.stringify({
jsonrpc: '2.0',
method: event,
params: data
}));
}
// Example: Send notification to client every 5 seconds
const interval = setInterval(() => {
notifyClient('serverTime', { time: new Date().toISOString() });
}, 5000);
ws.on('close', () => {
clearInterval(interval);
console.log('Client disconnected');
});
});
function handleRequest(request) {
const methods = {
ping: () => 'pong',
echo: (message) => message,
getServerInfo: () => ({
version: '1.0.0',
uptime: process.uptime()
})
};
if (request.id === undefined) {
// Notification - no response
if (methods[request.method]) {
methods[request.method](...(request.params || []));
}
return null;
}
if (!methods[request.method]) {
return {
jsonrpc: '2.0',
error: {code: -32601, message: 'Method not found'},
id: request.id
};
}
try {
const result = methods[request.method](...(request.params || []));
return {
jsonrpc: '2.0',
result,
id: request.id
};
} catch (error) {
return {
jsonrpc: '2.0',
error: {code: -32000, message: error.message},
id: request.id
};
}
}
console.log('WebSocket JSON-RPC server on ws://localhost:8080');
|
Client (JavaScript)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
| class JSONRPCWebSocketClient {
constructor(url) {
this.url = url;
this.ws = null;
this.requestId = 0;
this.pendingRequests = new Map();
this.eventHandlers = new Map();
}
connect() {
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('Connected');
resolve();
};
this.ws.onerror = (error) => {
reject(error);
};
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.id) {
// Response to our request
const pending = this.pendingRequests.get(message.id);
if (pending) {
this.pendingRequests.delete(message.id);
if (message.error) {
pending.reject(new Error(message.error.message));
} else {
pending.resolve(message.result);
}
}
} else {
// Server-initiated notification
this.handleNotification(message);
}
};
this.ws.onclose = () => {
console.log('Disconnected');
// Reject all pending requests
for (const [id, pending] of this.pendingRequests) {
pending.reject(new Error('Connection closed'));
}
this.pendingRequests.clear();
};
});
}
call(method, params = []) {
return new Promise((resolve, reject) => {
const id = ++this.requestId;
this.pendingRequests.set(id, { resolve, reject });
this.ws.send(JSON.stringify({
jsonrpc: '2.0',
method,
params,
id
}));
// Timeout after 30 seconds
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error('Request timeout'));
}
}, 30000);
});
}
notify(method, params = []) {
this.ws.send(JSON.stringify({
jsonrpc: '2.0',
method,
params
}));
}
on(method, handler) {
this.eventHandlers.set(method, handler);
}
handleNotification(message) {
const handler = this.eventHandlers.get(message.method);
if (handler) {
handler(...(message.params || []));
}
}
close() {
this.ws.close();
}
}
// Usage
const client = new JSONRPCWebSocketClient('ws://localhost:8080');
await client.connect();
// Register handler for server notifications
client.on('serverTime', (data) => {
console.log('Server time:', data.time);
});
// Call server method
const info = await client.call('getServerInfo');
console.log('Server info:', info);
// Send notification to server
client.notify('clientStatus', { status: 'active' });
|
Use cases for WebSocket JSON-RPC:
- Real-time dashboards (server pushes updates)
- Live collaboration (bidirectional sync)
- Game servers (low-latency actions)
- Trading platforms (price updates)
- Chat applications
- IoT device control
WebSocket Benefits:
- Bidirectional: Server can call client methods
- Low latency: No HTTP overhead per message
- Persistent: Single connection for multiple calls
- Efficient: No repeated headers
Trade-offs:
- Connection management complexity
- Not cacheable (unlike HTTP)
- Firewall/proxy challenges
- State management required
Real-World Use Cases
1. Ethereum JSON-RPC
Ethereum nodes expose a JSON-RPC API for blockchain interaction:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| // Get account balance
const balance = await client.call('eth_getBalance', [
'0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb',
'latest'
]);
// Get current block number
const blockNumber = await client.call('eth_blockNumber', []);
// Send transaction
const txHash = await client.call('eth_sendTransaction', [{
from: '0x...',
to: '0x...',
value: '0x9184e72a000', // 10000000000000 wei
gas: '0x5208' // 21000 gas
}]);
// Get transaction receipt
const receipt = await client.call('eth_getTransactionReceipt', [txHash]);
// Call smart contract (read-only)
const result = await client.call('eth_call', [{
to: '0x...', // Contract address
data: '0x...' // Encoded function call
}, 'latest']);
|
Why Ethereum uses JSON-RPC:
- Action-oriented (send transaction, get balance)
- Simple for wallet integrations
- Works over HTTP and WebSockets
- Easy to debug (human-readable)
- Wide language support
2. Language Server Protocol (LSP)
VS Code, Neovim, and other editors use JSON-RPC for language intelligence:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
| // Client → Server: Request code completion
{
"jsonrpc": "2.0",
"id": 1,
"method": "textDocument/completion",
"params": {
"textDocument": {
"uri": "file:///path/to/file.ts"
},
"position": {
"line": 10,
"character": 15
}
}
}
// Server → Client: Completion results
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"items": [
{
"label": "console",
"kind": 6,
"detail": "Console object"
},
{
"label": "const",
"kind": 14,
"detail": "const keyword"
}
]
}
}
// Server → Client: Publish diagnostics (notification)
{
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": {
"uri": "file:///path/to/file.ts",
"diagnostics": [
{
"range": {
"start": {"line": 5, "character": 10},
"end": {"line": 5, "character": 20}
},
"severity": 1,
"message": "Variable 'x' is not defined"
}
]
}
}
|
Why LSP uses JSON-RPC:
- Bidirectional (server sends diagnostics)
- Language-agnostic (any editor, any language server)
- Asynchronous (non-blocking operations)
- Standardized protocol
3. Bitcoin Core RPC
Bitcoin nodes expose management via JSON-RPC:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // Get blockchain info
const info = await client.call('getblockchaininfo', []);
// Get wallet balance
const balance = await client.call('getbalance', []);
// Send Bitcoin
const txid = await client.call('sendtoaddress', [
'1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', // Address
0.01, // Amount in BTC
'Payment for services' // Comment
]);
// Generate new address
const address = await client.call('getnewaddress', ['', 'bech32']);
// Get transaction details
const tx = await client.call('gettransaction', [txid]);
|
4. Discord Bot API
Discord bots can use JSON-RPC for command handling:
1
2
3
4
5
6
7
8
9
10
11
12
13
| // Register command handler
client.on('sendMessage', async ({channelId, content}) => {
// Send message to Discord channel
await discord.channels.get(channelId).send(content);
return {success: true};
});
// Bot receives command from Discord
await rpcClient.call('onMessage', {
author: 'User#1234',
content: '!hello',
channelId: '123456789'
});
|
5. Internal Microservices
JSON-RPC for service-to-service communication:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // User service
const userService = new JSONRPCClient('http://user-service/rpc');
// Order service calls user service
const user = await userService.call('getUserById', {id: 123});
const address = await userService.call('getUserAddress', {
userId: 123,
addressType: 'shipping'
});
// Batch request for efficiency
const [user, orders, preferences] = await userService.batch([
{method: 'getUserById', params: {id: 123}},
{method: 'getUserOrders', params: {userId: 123, limit: 10}},
{method: 'getUserPreferences', params: {userId: 123}}
]);
|
Benefits for microservices:
- Simpler than REST for action-oriented APIs
- Batch requests reduce network overhead
- Easy to version (method names like
v2.getUser) - Self-documenting method names
JSON-RPC vs REST vs gRPC
Comparison Table
| Aspect | JSON-RPC | REST | gRPC |
|---|
| Philosophy | Action-oriented (functions) | Resource-oriented (CRUD) | Action-oriented (services) |
| Protocol | JSON over HTTP/WebSocket | HTTP methods + URLs | Protobuf over HTTP/2 |
| Request format | JSON with method name | HTTP verb + path | Binary Protobuf |
| Response format | JSON result or error | HTTP status + body | Binary Protobuf |
| Type safety | No (runtime only) | No | Yes (schema required) |
| Schema | Optional (JSON Schema) | Optional (OpenAPI) | Required (.proto files) |
| Batch operations | Native (array of requests) | Not standardized | Streaming |
| Bidirectional | With WebSockets | No | Yes (HTTP/2 streams) |
| Browser support | Excellent | Excellent | Limited (gRPC-Web needed) |
| Human-readable | Yes | Yes | No (binary) |
| Performance | Good | Good | Excellent |
| Learning curve | Low | Low | Medium |
| Tooling | Moderate | Excellent | Excellent |
| Versioning | Method names | URL paths | Protobuf evolution |
| Caching | Manual | HTTP caching | Manual |
When to Use Each
Use JSON-RPC when:
- Action-oriented domain (calculations, operations)
- Internal microservices (simplicity matters)
- Batch operations needed
- WebSocket support required
- Rapid prototyping (no schema needed)
Use REST when:
- Resource-oriented domain (CRUD operations)
- Public APIs (standardization matters)
- HTTP caching benefits your use case
- Stateless operations
- Wide client compatibility needed
Use gRPC when:
- Performance critical (high throughput)
- Strong typing required
- Schema evolution important
- Microservices with complex contracts
- Language-agnostic code generation needed
flowchart TB
start{What's your domain?}
start -->|Resources with CRUD| rest[REST]
start -->|Actions and operations| rpc{Need schema?}
rpc -->|No, flexibility| jsonrpc[JSON-RPC]
rpc -->|Yes, type safety| grpc[gRPC]
rest -->|Public API| restpublic[REST + OpenAPI]
rest -->|Internal| restinternal[REST]
jsonrpc -->|HTTP| jsonrpchttp[JSON-RPC over HTTP]
jsonrpc -->|Real-time| jsonrpcws[JSON-RPC over WebSockets]
grpc -->|Browser clients| grpcweb[gRPC-Web]
grpc -->|Server-to-server| grpcnative[gRPC]
style rest fill:#3A4A5C,stroke:#6b7280,color:#f0f0f0
style jsonrpc fill:#3A4C43,stroke:#6b7280,color:#f0f0f0
style grpc fill:#4C4538,stroke:#6b7280,color:#f0f0f0
Hybrid Approaches
Many systems use multiple protocols:
Example: E-commerce platform
REST: Public product catalog API
JSON-RPC: Internal order processing service
gRPC: High-performance inventory service
WebSockets: Real-time order status updates
Example: Financial system
REST: Account management API
JSON-RPC: Transaction execution service
gRPC: Market data feeds
WebSockets: Live price updates
Don’t feel locked into one protocol. Choose based on each API’s characteristics.
Best Practices
1. Use Named Parameters
1
2
3
4
5
6
7
8
9
10
| // Bad: Positional parameters
{"method": "createUser", "params": ["alice", "alice@example.com", 30, true]}
// Good: Named parameters
{"method": "createUser", "params": {
"username": "alice",
"email": "alice@example.com",
"age": 30,
"active": true
}}
|
Named parameters are self-documenting and make optional parameters easier.
2. Version Your Methods
1
2
3
4
5
| // Version in method name
client.call('v2.getUser', {id: 123});
// Or use a parameter
client.call('getUser', {version: 2, id: 123});
|
Allows gradual migration without breaking existing clients.
3. Use Standard Error Codes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| const ErrorCodes = {
// Standard JSON-RPC codes
PARSE_ERROR: -32700,
INVALID_REQUEST: -32600,
METHOD_NOT_FOUND: -32601,
INVALID_PARAMS: -32602,
INTERNAL_ERROR: -32603,
// Application codes (start at -32000 or use positive)
UNAUTHORIZED: -32001,
FORBIDDEN: -32002,
NOT_FOUND: -32003,
VALIDATION_ERROR: -32004,
RATE_LIMIT: -32005
};
|
Consistent error codes make client error handling easier.
4. Add Request Timeouts
1
2
3
4
5
| // Client-side timeout
const result = await client.call('longOperation', {}, {timeout: 60000});
// Server-side timeout
app.post('/rpc', timeout('30s'), handleRPC);
|
Prevents hanging requests from exhausting resources.
5. Implement Request Logging
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| app.use((req, res, next) => {
if (req.path === '/rpc') {
const start = Date.now();
const requestId = generateId();
console.log('RPC Request', {
id: requestId,
method: req.body.method,
params: req.body.params
});
res.on('finish', () => {
console.log('RPC Response', {
id: requestId,
duration: Date.now() - start,
status: res.statusCode
});
});
}
next();
});
|
Essential for debugging and monitoring.
6. Use Batch Requests for Efficiency
1
2
3
4
5
6
7
8
9
10
11
| // Instead of 100 separate requests
for (const id of userIds) {
await client.call('getUser', {id});
}
// Single batch request
const calls = userIds.map(id => ({
method: 'getUser',
params: {id}
}));
const users = await client.batch(calls);
|
Dramatically reduces latency and connection overhead.
7. Validate Parameters Early
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| const schemas = {
'createUser': {
username: {type: 'string', minLength: 3, maxLength: 20},
email: {type: 'string', format: 'email'},
age: {type: 'number', minimum: 0}
}
};
function validateParams(method, params) {
const schema = schemas[method];
if (!schema) return true;
return validate(params, schema);
}
|
Return -32602 (Invalid params) early if validation fails.
8. Implement Rate Limiting
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| const rateLimit = require('express-rate-limit');
app.use('/rpc', rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
handler: (req, res) => {
res.json({
jsonrpc: '2.0',
error: {
code: -32005,
message: 'Rate limit exceeded'
},
id: req.body.id
});
}
}));
|
Protect against abuse and DoS attacks.
9. Use Connection Pooling
1
2
3
4
5
6
7
| // HTTP client with connection pooling
const client = new JSONRPCClient('http://api/rpc', {
agent: new http.Agent({
keepAlive: true,
maxSockets: 50
})
});
|
Reuse TCP connections for better performance.
10. Document Your Methods
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| /**
* Get user by ID
* @method getUser
* @param {number} id - User ID
* @returns {Object} User object with id, username, email
* @throws {-32003} User not found
*/
methods['getUser'] = async ({id}) => {
const user = await db.users.findById(id);
if (!user) {
const error = new Error('User not found');
error.code = -32003;
throw error;
}
return user;
};
|
Clear documentation is essential for API consumers.
Security Considerations
1. Authentication
Token-based:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| app.post('/rpc', authenticateToken, handleRPC);
function authenticateToken(req, res, next) {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.json({
jsonrpc: '2.0',
error: {code: -32001, message: 'Unauthorized'},
id: req.body.id
});
}
try {
req.user = verifyJWT(token);
next();
} catch (err) {
return res.json({
jsonrpc: '2.0',
error: {code: -32001, message: 'Invalid token'},
id: req.body.id
});
}
}
|
Client usage:
1
2
3
4
5
| const client = new JSONRPCClient('http://api/rpc', {
headers: {
'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
}
});
|
2. Authorization
Check permissions per method:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| const methodPermissions = {
'getUser': ['user', 'admin'],
'createUser': ['admin'],
'deleteUser': ['admin']
};
function authorize(method, user) {
const required = methodPermissions[method];
if (!required) return true; // No restrictions
return required.includes(user.role);
}
// In handler
if (!authorize(request.method, req.user)) {
return {
jsonrpc: '2.0',
error: {code: -32002, message: 'Forbidden'},
id: request.id
};
}
|
Never trust client input:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| const Joi = require('joi');
const schemas = {
'createUser': Joi.object({
username: Joi.string().alphanum().min(3).max(20).required(),
email: Joi.string().email().required(),
age: Joi.number().integer().min(0).max(150)
})
};
function validateInput(method, params) {
const schema = schemas[method];
if (!schema) return {valid: true};
const {error, value} = schema.validate(params);
return error ? {valid: false, error: error.message} : {valid: true, value};
}
|
4. CORS Configuration
1
2
3
4
5
6
7
8
| const cors = require('cors');
app.use('/rpc', cors({
origin: 'https://yourdomain.com',
credentials: true,
methods: ['POST'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
|
5. HTTPS Only
1
2
3
4
5
6
7
| // Redirect HTTP to HTTPS
app.use((req, res, next) => {
if (!req.secure && process.env.NODE_ENV === 'production') {
return res.redirect(`https://${req.headers.host}${req.url}`);
}
next();
});
|
6. Prevent Timing Attacks
1
2
3
4
5
6
7
8
9
| const crypto = require('crypto');
function safeCompare(a, b) {
// Use constant-time comparison
return crypto.timingSafeEqual(
Buffer.from(a),
Buffer.from(b)
);
}
|
7. Sanitize Error Messages
1
2
3
4
5
6
7
8
9
10
| // Don't expose internal details
try {
await db.query('SELECT * FROM users WHERE id = ?', [id]);
} catch (err) {
// Bad: exposes database structure
throw new Error(err.message);
// Good: generic message
throw new Error('Database error');
}
|
Testing JSON-RPC APIs
Unit Tests (Server)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
| const request = require('supertest');
const app = require('./app');
describe('JSON-RPC Server', () => {
test('should add two numbers', async () => {
const response = await request(app)
.post('/rpc')
.send({
jsonrpc: '2.0',
method: 'add',
params: [5, 3],
id: 1
});
expect(response.status).toBe(200);
expect(response.body.result).toBe(8);
expect(response.body.id).toBe(1);
});
test('should return error for unknown method', async () => {
const response = await request(app)
.post('/rpc')
.send({
jsonrpc: '2.0',
method: 'unknownMethod',
params: [],
id: 1
});
expect(response.body.error.code).toBe(-32601);
expect(response.body.error.message).toContain('Method not found');
});
test('should handle batch requests', async () => {
const response = await request(app)
.post('/rpc')
.send([
{jsonrpc: '2.0', method: 'add', params: [1, 2], id: 1},
{jsonrpc: '2.0', method: 'subtract', params: [5, 3], id: 2}
]);
expect(response.body).toHaveLength(2);
expect(response.body[0].result).toBe(3);
expect(response.body[1].result).toBe(2);
});
test('should handle notifications', async () => {
const response = await request(app)
.post('/rpc')
.send({
jsonrpc: '2.0',
method: 'logEvent',
params: {level: 'info', message: 'test'}
});
expect(response.status).toBe(204);
});
});
|
Integration Tests (Client)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| const client = new JSONRPCClient('http://localhost:3000/rpc');
describe('JSON-RPC Client', () => {
test('should call remote method', async () => {
const result = await client.call('add', [10, 20]);
expect(result).toBe(30);
});
test('should handle errors', async () => {
await expect(client.call('divide', [10, 0]))
.rejects.toThrow('Division by zero');
});
test('should send batch requests', async () => {
const results = await client.batch([
{method: 'add', params: [1, 2]},
{method: 'multiply', params: [3, 4]}
]);
expect(results).toEqual([3, 12]);
});
});
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| const autocannon = require('autocannon');
// Load test
autocannon({
url: 'http://localhost:3000/rpc',
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'add',
params: [5, 3],
id: 1
}),
connections: 100,
duration: 10
}, (err, result) => {
console.log('Requests/sec:', result.requests.mean);
console.log('Latency (ms):', result.latency.mean);
});
|
Conclusion: JSON-RPC’s Sweet Spot
JSON-RPC fills the gap between REST’s resource orientation and gRPC’s performance overhead. It’s the pragmatic choice for action-oriented APIs.
What We Learned
JSON-RPC provides:
- Simple specification (8 pages)
- Action-oriented paradigm (functions, not resources)
- Transport-agnostic (HTTP, WebSockets, any byte stream)
- Batch request support (reduce network overhead)
- Bidirectional communication (with WebSockets)
- Wide adoption (Ethereum, LSP, Bitcoin)
Key patterns:
- Named parameters over positional
- Standard error codes for consistency
- Batch requests for efficiency
- WebSockets for real-time bidirectional RPC
- Middleware for auth, logging, rate limiting
When to use JSON-RPC:
- Internal microservices (simplicity matters)
- Action-oriented domains (calculations, operations)
- Real-time applications (WebSocket support)
- Systems needing batch operations
- Rapid prototyping (no schema required)
When to avoid:
- Public REST APIs (standardization matters)
- Resource-oriented CRUD (REST is more natural)
- Performance-critical systems (use gRPC)
- Need strong typing (use gRPC with Protobuf)
Series Progress:
- Part 1: JSON’s origins and fundamental weaknesses
- Part 2: JSON Schema for validation and contracts
- Part 3: Binary JSON in databases (JSONB, BSON)
- Part 4: Binary JSON for APIs (MessagePack, CBOR)
- Part 5 (this article): JSON-RPC protocol and patterns
- Part 6: Streaming JSON with JSON Lines
- Part 7: Security (JWT, canonicalization, attacks)
In Part 5, we’ll tackle streaming JSON with JSON Lines (JSONL) - solving JSON’s inability to handle large datasets that don’t fit in memory. We’ll explore newline-delimited JSON for log processing, data pipelines, and Unix-style streaming.
Next: Part 5 - Streaming JSON: Processing Gigabytes Without Running Out of Memory
Further Reading
Specifications:
Real-World Implementations:
Libraries:
Related: