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.

CodeMessageDescription
-32700Parse errorInvalid JSON received
-32600Invalid RequestJSON not a valid Request object
-32601Method not foundMethod does not exist or is unavailable
-32602Invalid paramsInvalid method parameters
-32603Internal errorInternal JSON-RPC error
-32000 to -32099Server errorReserved 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.