JSON-RPC Best Practices
Following best practices when designing and implementing JSON-RPC APIs can help ensure your services are secure, performant, maintainable, and user-friendly. This guide compiles expert recommendations for building robust JSON-RPC services for a variety of applications.
API Design Principles
Good API design is critical for creating JSON-RPC services that are intuitive, consistent, and maintainable. These principles will help you design effective JSON-RPC APIs.
Use Consistent Method Naming
Adopt a consistent naming pattern for your methods. This makes your API intuitive and predictable.
✓ Good Practice
user.create
user.update
user.get
user.delete
✗ Bad Practice
createUser
updateUserRecord
getUser
removeUser
Define Clear Parameter Structures
Use named parameters (objects) instead of positional parameters (arrays) for clarity and flexibility.
✓ Good Practice
{ "jsonrpc": "2.0", "method": "user.create", "params": { "username": "johndoe", "email": "[email protected]", "role": "editor" }, "id": 1 }
✗ Bad Practice
{ "jsonrpc": "2.0", "method": "user.create", "params": ["johndoe", "[email protected]", "editor"], "id": 1 }
Group Related Methods
Use namespaces or prefixes to group related methods. This improves organization and discoverability.
✓ Good Practice
- User management:
user.create
,user.update
,user.delete
- Authentication:
auth.login
,auth.logout
,auth.resetPassword
- Content:
content.create
,content.publish
,content.archive
Keep Methods Focused
Each method should do one thing well. Avoid creating "Swiss Army knife" methods that perform multiple operations.
✓ Good Practice
user.create
user.assignRole
notification.send
✗ Bad Practice
user.createAndAssignRoleAndNotify
Implement Pagination for List Results
When returning lists of items, implement pagination to control response size and improve performance.
✓ Good Practice
// Request { "jsonrpc": "2.0", "method": "user.list", "params": { "page": 2, "limit": 10, "filters": {"role": "editor"} }, "id": 1 } // Response { "jsonrpc": "2.0", "result": { "items": [ /* user objects */ ], "total": 142, "page": 2, "limit": 10, "pages": 15 }, "id": 1 }
Security Guidelines
Security is paramount when designing any API. JSON-RPC services face specific security challenges that need to be addressed to protect both your service and your users.
Use HTTPS Transport
Always use HTTPS to encrypt data in transit. This prevents man-in-the-middle attacks and eavesdropping.
Important: Never transmit sensitive data such as authentication credentials over unencrypted connections, even in development environments.
Implement Proper Authentication
Use industry-standard authentication methods such as JWT, OAuth 2.0, or API keys. Add authentication information in HTTP headers rather than in the JSON-RPC request body.
✓ Good Practice
// HTTP Header Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... // JSON-RPC Request Body { "jsonrpc": "2.0", "method": "user.get", "params": {"id": 123}, "id": 1 }
✗ Bad Practice
{ "jsonrpc": "2.0", "method": "user.get", "params": { "id": 123, "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }, "id": 1 }
Input Validation and Sanitization
Validate all method parameters against a schema before processing. This prevents injection attacks and ensures data integrity.
// Example using JSON Schema validation in Node.js const Ajv = require('ajv'); const ajv = new Ajv(); const userCreateSchema = { type: 'object', properties: { username: { type: 'string', pattern: '^[a-zA-Z0-9_]{3,30}$' }, email: { type: 'string', format: 'email' }, role: { type: 'string', enum: ['user', 'editor', 'admin'] } }, required: ['username', 'email', 'role'], additionalProperties: false }; function handleUserCreate(params) { const valid = ajv.validate(userCreateSchema, params); if (!valid) { throw {code: -32602, message: 'Invalid params', data: ajv.errors}; } // Process the request... }
Implement Method-Level Authorization
Even after authentication, verify that the authenticated user has permission to call the specific method with the provided parameters.
// Example authorization check function handleUserDelete(params, context) { const userId = params.id; const currentUser = context.user; // Self-delete or admin-only operation if (userId !== currentUser.id && currentUser.role !== 'admin') { throw { code: -32600, message: 'Unauthorized operation', data: 'Only admins can delete other users' }; } // Process the request... }
Implement Rate Limiting
Protect your API from abuse by limiting the number of requests a client can make in a given time period. This helps prevent brute-force attacks and denial-of-service attacks.
Tip: Include rate limit information in response headers:X-RateLimit-Limit: 100
(requests per time window)X-RateLimit-Remaining: 45
(remaining requests in current window)X-RateLimit-Reset: 1605126000
(unix timestamp when window resets)
Limit Batch Request Size
While batch processing is a powerful feature of JSON-RPC, it can be abused. Set reasonable limits on the number of operations in a single batch request.
Implementation Example
// Check batch size limit function handleBatchRequest(batchRequest) { const MAX_BATCH_SIZE = 20; if (batchRequest.length > MAX_BATCH_SIZE) { return { jsonrpc: '2.0', error: { code: -32600, message: 'Invalid Request', data: `Batch size exceeds maximum of ${MAX_BATCH_SIZE} operations` }, id: null }; } // Process batch request... }
Security Warning
Never use eval()
or similar dynamic execution mechanisms with client inputs. Always have a whitelist of allowed methods rather than directly mapping method names to functions. This prevents attackers from calling arbitrary functions in your codebase.
Error Handling
Effective error handling is crucial for building robust JSON-RPC APIs. Proper error responses help clients understand issues and take appropriate recovery actions.
Use Standard Error Codes
The JSON-RPC 2.0 specification defines a set of standard error codes. Always use these standard codes instead of creating your own.
Code | Message | Description |
---|---|---|
-32700 | Parse error | Invalid JSON received |
-32600 | Invalid Request | JSON not a valid Request object |
-32601 | Method not found | Method does not exist or is unavailable |
-32602 | Invalid params | Invalid method parameters |
-32603 | Internal error | Internal JSON-RPC error |
-32000 to -32099 | Server error | Reserved for implementation-defined server errors |
Provide Detailed Error Data
Use the optional data
field to provide additional information about the error, which helps clients better understand and handle it.
Detailed Error Example
{ "jsonrpc": "2.0", "error": { "code": -32602, "message": "Invalid params", "data": { "fields": { "email": ["Must be a valid email address"], "username": ["Must be at least 3 characters long"] }, "requestId": "a7f5239b-d2c4-4631-9835-d53c05a9b0a5" } }, "id": 1 }
Distinguish Between Protocol and Business Errors
Application-specific errors (like "Insufficient balance") should use user-defined error codes (based on your own convention, but avoid conflict with standard codes).
Protocol Error
{ "jsonrpc": "2.0", "error": { "code": -32602, "message": "Invalid params" }, "id": 1 }
Business Logic Error
{ "jsonrpc": "2.0", "error": { "code": 100, // Custom code "message": "Insufficient balance", "data": { "balance": 50, "requiredAmount": 100 } }, "id": 1 }
Error Handling in Batch Requests
When processing batch requests, handle errors for each request separately. An error in one request should not affect the processing of other requests in the batch.
Batch Error Handling Example
// Batch request [ {"jsonrpc": "2.0", "method": "user.get", "params": {"id": 1}, "id": 1}, {"jsonrpc": "2.0", "method": "nonexistent", "params": {}, "id": 2}, {"jsonrpc": "2.0", "method": "user.create", "params": {"email": "invalid"}, "id": 3} ] // Batch response [ {"jsonrpc": "2.0", "result": {"id": 1, "name": "John"}, "id": 1}, { "jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": 2 }, { "jsonrpc": "2.0", "error": { "code": -32602, "message": "Invalid params", "data": {"email": ["Not a valid email address"]} }, "id": 3 } ]
Request ID Handling
Always include the same ID in error responses as in the original request. Use null
if the ID cannot be determined (e.g., in case of a parse error).
Parse Error Example
// Client sends invalid JSON { this is not valid JSON } // Server response { "jsonrpc": "2.0", "error": { "code": -32700, "message": "Parse error" }, "id": null // ID is unknown, so null is used }
Detailed Code Example
Here's a comprehensive error handling implementation example:
// Node.js error handling example function handleJsonRpcRequest(request) { try { // Parse JSON request const parsedRequest = typeof request === 'string' ? JSON.parse(request) : request; // Handle batch requests if (Array.isArray(parsedRequest)) { return parsedRequest.map(singleRequest => { try { return handleSingleRequest(singleRequest); } catch (err) { return formatJsonRpcError(err, singleRequest?.id); } }); } // Handle single request return handleSingleRequest(parsedRequest); } catch (err) { // Handle JSON parsing errors if (err instanceof SyntaxError) { return { jsonrpc: '2.0', error: { code: -32700, message: 'Parse error', data: err.message }, id: null }; } // Handle other uncaught errors return formatJsonRpcError(err, request?.id); } } function formatJsonRpcError(err, id) { // Extract error code and message let code, message, data; // Handle custom JsonRpcError type if (err.isJsonRpcError) { code = err.code; message = err.message; data = err.data; } // Handle validation errors else if (err.name === 'ValidationError') { code = -32602; message = 'Invalid params'; data = err.details; } // Handle method not found else if (err.name === 'NotFoundError') { code = -32601; message = 'Method not found'; } // Handle other errors else { code = -32603; message = 'Internal error'; // Don't leak internal error details in production data = process.env.NODE_ENV === 'production' ? undefined : err.message; // Log internal errors for debugging console.error('JSON-RPC internal error:', err); } return { jsonrpc: '2.0', error: { code, message, ...(data !== undefined && { data }) }, id: id ?? null }; }
Performance Optimization
Performance is critical for providing a good user experience. These best practices will help you optimize your JSON-RPC services for speed and efficiency.
Use Batch Requests Effectively
Encourage clients to use batch requests to reduce the number of HTTP round-trips, especially for operations that are logically related or typically performed together.
Batch Request Example
// Instead of three separate requests, use a single batch request [ { "jsonrpc": "2.0", "method": "post.get", "params": {"id": 42}, "id": 1 }, { "jsonrpc": "2.0", "method": "user.get", "params": {"id": 15}, "id": 2 }, { "jsonrpc": "2.0", "method": "comment.list", "params": {"postId": 42}, "id": 3 } ]
Minimize Response Payload Size
Only include the data that clients actually need in your responses. Provide options for clients to request specific fields or relationships.
✓ Good Practice
// Request with fields parameter { "jsonrpc": "2.0", "method": "user.get", "params": { "id": 123, "fields": ["id", "name", "email"] }, "id": 1 } // Response includes only requested fields { "jsonrpc": "2.0", "result": { "id": 123, "name": "John Doe", "email": "[email protected]" }, "id": 1 }
✗ Bad Practice
// Response with unnecessary data { "jsonrpc": "2.0", "result": { "id": 123, "name": "John Doe", "email": "[email protected]", "address": "...", "phoneNumber": "...", "dateOfBirth": "...", "preferences": { ... }, "createdAt": "...", "updatedAt": "...", // ... many more fields }, "id": 1 }
Implement Caching
Use HTTP caching mechanisms for read-only operations. Include proper cache control headers to improve performance and reduce server load.
HTTP Headers for Caching
// For cacheable responses Cache-Control: max-age=3600, public ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4" // For responses that shouldn't be cached Cache-Control: no-store, max-age=0
Optimize Database Queries
Ensure your backend implementation efficiently queries databases, especially when handling batch requests.
Batch Database Query Example
// Instead of: for (const id of userIds) { const user = await db.users.findById(id); results.push(user); } // Use: const users = await db.users.findByIds(userIds);
Use Compression
Enable HTTP compression (gzip or brotli) to reduce the size of request and response payloads.
Tip: Most web servers and frameworks support compression out of the box. Make sure it's enabled for your JSON-RPC endpoints.
Connection Reuse
Encourage clients to use HTTP/2 and connection keep-alive to reduce the overhead of establishing new connections.
HTTP/2 Benefits
- Multiplexing multiple requests over a single connection
- Header compression
- Server push (can be used to optimize loading related resources)
- Binary protocol with lower overhead
Testing Strategies
Comprehensive testing of your JSON-RPC API is crucial for ensuring its correctness, reliability, and performance. Here are recommended approaches for testing JSON-RPC services.
Unit Testing
Unit test individual method handlers to verify their behavior under various input conditions.
Unit Test Example (Jest)
test('user.create validates required fields', async () => { // Missing required email field const params = { username: 'johndoe', password: 'secret123' }; // Call the handler try { await userMethods.create(params); fail('Should have thrown validation error'); } catch (error) { // Verify error response expect(error.code).toBe(-32602); expect(error.message).toBe('Invalid params'); expect(error.data).toHaveProperty('email'); } });
Integration Testing
Test the complete JSON-RPC request-response flow, including request parsing, validation, processing, and response formatting.
Integration Test Example
test('processes valid JSON-RPC request', async () => { const request = { jsonrpc: '2.0', method: 'user.get', params: { id: 123 }, id: 1 }; const response = await processJsonRpcRequest(request); expect(response.jsonrpc).toBe('2.0'); expect(response).toHaveProperty('result'); expect(response.id).toBe(1); }); test('returns error for invalid method', async () => { const request = { jsonrpc: '2.0', method: 'nonexistent.method', params: {}, id: 1 }; const response = await processJsonRpcRequest(request); expect(response.jsonrpc).toBe('2.0'); expect(response).toHaveProperty('error'); expect(response.error.code).toBe(-32601); expect(response.id).toBe(1); });
End-to-End Testing
Perform full end-to-end tests via HTTP or other transport layers to verify the complete system behavior.
End-to-End Test Example (using supertest)
test('API endpoint processes JSON-RPC request', async () => { const response = await request(app) .post('/rpc') .set('Content-Type', 'application/json') .send({ jsonrpc: '2.0', method: 'user.create', params: { username: 'testuser', email: '[email protected]', password: 'password123' }, id: 1 }); expect(response.status).toBe(200); expect(response.body.jsonrpc).toBe('2.0'); expect(response.body).toHaveProperty('result'); expect(response.body.result).toHaveProperty('id'); expect(response.body.id).toBe(1); });
Load Testing
Test the API's performance and stability under high load conditions to ensure it can handle expected traffic.
Load Testing with k6
import http from 'k6/http'; import { sleep, check } from 'k6'; export const options = { vus: 50, // 50 virtual users duration: '30s', // test runs for 30 seconds }; export default function() { const url = 'https://api.example.com/rpc'; const payload = JSON.stringify({ jsonrpc: '2.0', method: 'user.get', params: { id: 1 }, id: 1 }); const params = { headers: { 'Content-Type': 'application/json', }, }; const res = http.post(url, payload, params); check(res, { 'is status 200': (r) => r.status === 200, 'has valid response': (r) => r.json().result !== undefined, 'response time < 200ms': (r) => r.timings.duration < 200, }); sleep(1); }
Batch Processing Best Practices
Batch processing is a powerful feature of JSON-RPC that allows clients to send multiple JSON-RPC calls in a single request. Here are best practices for effectively using batching.
When to Use Batching
Batching is appropriate for the following scenarios:
- Fetching multiple related resources (e.g., user, permissions, and settings)
- Performing a group of related operations (e.g., creating multiple related entities)
- Reducing round-trips for mobile devices or high-latency connections
- Cases where multiple operations need to be executed atomically (note that JSON-RPC itself doesn't guarantee atomicity)
Batch Size Limits
To prevent abuse and resource exhaustion, set reasonable batch size limits:
- Limit the number of requests in a single batch (e.g., no more than 20-25 requests)
- Limit the total size of batch requests (e.g., no more than 1MB)
- Adjust these limits based on your service's resource capacity and performance characteristics
Warning: Not limiting batch sizes can lead to server resource exhaustion and even DOS attacks.
Batch Processing Strategies
Consider the following processing strategies to improve batch efficiency:
Parallel Processing
Use asynchronous processing to execute requests in a batch in parallel, especially when requests have no dependencies.
// Process batch requests in parallel const responses = await Promise.all( batchRequest.map(async (request) => { try { return await processRequest(request); } catch (err) { return formatError(err, request.id); } }) );
Batch Database Operations
Optimize database access by using bulk queries instead of multiple individual queries.
// Collect all user IDs from the batch const userIds = batchRequest .filter(req => req.method === 'user.get') .map(req => req.params.id); // Fetch all users at once const users = await db.users.findByIds(userIds); // Use the preloaded users when processing individual requests
Client-Side Batching Strategies
For JSON-RPC client implementations:
- Provide a simple API for creating and sending batches
- Allow clients to add requests to a batch queue and automatically send batches
- Coalesce requests at appropriate times (e.g., within a single UI update cycle)
- Handle partially successful batches (some requests succeed, some fail)
Client Batching Example
// Simple batching client class JsonRpcBatchClient { constructor(endpoint) { this.endpoint = endpoint; this.batch = []; this.idCounter = 1; } addRequest(method, params) { const id = this.idCounter++; this.batch.push({ jsonrpc: '2.0', method, params, id }); return id; } addNotification(method, params) { this.batch.push({ jsonrpc: '2.0', method, params }); } async send() { if (this.batch.length === 0) return []; const response = await fetch(this.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.batch) }); const results = await response.json(); const batch = this.batch; this.batch = []; // Clear the batch queue // Map results back to original requests return results.map(result => { const request = batch.find(req => req.id === result.id); return { request, result: result.result, error: result.error }; }); } }
Monitoring & Logging
Effective monitoring and logging are essential for maintaining healthy, high-performance JSON-RPC services.
Key Metrics to Monitor
Monitor the following key metrics to understand the health and performance of your JSON-RPC service:
- Request Rate: Requests processed per second/minute
- Response Time: Average, median, P95, P99 response times
- Error Rate: Percentage of errors, categorized by error type (client errors, server errors)
- Batch Size: Average number of operations in batch requests
- Active Connections: Number of connections currently being processed
- System Resources: CPU, memory, disk I/O, network I/O
- Cache Hit Rate: If using caching, monitor hit rate and efficiency
Structured Logging
Implement structured logging for easier analysis and troubleshooting:
JSON Log Example
{ "timestamp": "2023-04-15T08:30:00.123Z", "level": "info", "service": "json-rpc-api", "requestId": "a7f5239b-d2c4-4631-9835-d53c05a9b0a5", "method": "user.create", "elapsedMs": 45, "userId": 123, "clientIp": "192.168.1.1", "userAgent": "Mozilla/5.0...", "message": "JSON-RPC request processed successfully" }
Log the following, while being mindful of privacy for sensitive data:
- Request ID and method name
- Processing time
- Error details (if any)
- Batch request size
- User ID or session ID (if applicable)
- Response code and result status
Implement Health Checks
Provide a health check endpoint that load balancers and monitoring systems can use to check service health.
Health Check Example
// Health check endpoint (Express) app.get('/health', (req, res) => { const status = { status: 'ok', timestamp: new Date().toISOString(), version: '1.0.0', services: { database: isDatabaseConnected ? 'ok' : 'error', cache: isCacheConnected ? 'ok' : 'error' }, metrics: { uptime: process.uptime(), requestsTotal: metrics.requestsTotal, errorRate: metrics.calculateErrorRate() } }; const isHealthy = status.services.database === 'ok'; res.status(isHealthy ? 200 : 503) .json(status); });
Alerts and Notifications
Set up alert triggers for critical metrics:
- Error rate exceeding threshold (e.g., 5% of requests failing)
- Response time abnormally increasing (e.g., P95 response time over 500ms)
- Request rate dropping significantly (may indicate upstream service issues)
- System resource utilization too high (e.g., CPU usage > 80%)
- Abnormal batch sizes (potential abuse)
Tip: Use tools like Prometheus, Grafana, ELK Stack, or CloudWatch to implement monitoring and alerting strategies.