Skip to content
gohacks.dev
APIs

JSON RPC in Go and the Cases Where It Beats Plain REST

Implement a JSON-RPC 2.0 server in Go with batches, notifications and the standard error codes, and see where it fits better than resource-oriented REST.

9 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response, with the JSON logo.

Not every API is a set of resources. Some are a set of verbs. Restart this worker, reindex that collection, estimate the gas for this transaction, format this document, run this tool. Squeezing those into POST /workers/7/restarts is possible, and it often reads as ceremony around a function call. JSON-RPC is the opposite design: you name the method you want, pass parameters, and get a result or an error.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

The protocol is small enough to hold in your head and, in Go, small enough to implement in an afternoon. This article does that: a server for JSON-RPC 2.0 with the pieces people usually skip, namely notifications, batches and the error codes. It then looks at where the style fits and where it does not, which is the question that decides whether you should reach for it. If you want the resource-oriented alternative, the REST API article covers it, and JSON encoding in Go explains the decoding details this code relies on. It ran on Go 1.25.6.

What the spec says

A JSON-RPC 2.0 request is an object with four members:

JSON
{"jsonrpc":"2.0","method":"math.add","params":{"a":2,"b":3},"id":1}

jsonrpc is the string "2.0". method is a name. params is optional and is either an object (named) or an array (positional). id is a string, a number or null, chosen by the client, and echoed in the reply so a client with many calls in flight can match answers to questions.

A request with no id member is a notification. The server must not reply to it, even if the method fails. That is useful for fire-and-forget messages and awkward if you forget it.

A response has either result or error, never both, and the same id as the request. The error object has an integer code, a message and optional data. The spec reserves a few codes:

CodeMeaning
-32700Parse error: the body is not valid JSON
-32600Invalid Request: valid JSON, not a valid request object
-32601Method not found
-32602Invalid params
-32603Internal error
-32000 to -32099Reserved for server-defined errors

A batch is a JSON array of requests. The server returns an array of responses in any order, omitting entries for notifications. An empty array is an invalid request.

The spec says nothing about the transport. It works over HTTP, WebSocket, a TCP socket, a pipe or standard input. Language servers use it over stdio, Ethereum nodes over HTTP and WebSocket, and several tool protocols for AI clients are built on it.

A dispatcher in Go

The complete server is below. It uses json.RawMessage in two places for a reason: Params stays raw so each method decodes it into its own type, and ID stays raw because it can be a string, a number or null, and because a missing id (a notification) has to be told apart from null.

main.goGo
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
)

type request struct {
	JSONRPC string          `json:"jsonrpc"`
	Method  string          `json:"method"`
	Params  json.RawMessage `json:"params"`
	ID      json.RawMessage `json:"id"` // absent means a notification
}

type response struct {
	JSONRPC string          `json:"jsonrpc"`
	Result  any             `json:"result,omitempty"`
	Error   *rpcError       `json:"error,omitempty"`
	ID      json.RawMessage `json:"id"`
}

type rpcError struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
}

type handlerFunc func(params json.RawMessage) (any, error)

var errInvalidParams = errors.New("invalid params")

func add(params json.RawMessage) (any, error) {
	var p struct{ A, B int }
	if err := json.Unmarshal(params, &p); err != nil {
		return nil, errInvalidParams
	}
	return p.A + p.B, nil
}

var methods = map[string]handlerFunc{
	"math.add": add,
	"ping":     func(json.RawMessage) (any, error) { return "pong", nil },
	"boom":     func(json.RawMessage) (any, error) { return nil, errors.New("database unavailable") },
}

// call runs one request and returns nil for a notification.
func call(raw json.RawMessage) *response {
	var r request
	if err := json.Unmarshal(raw, &r); err != nil || r.JSONRPC != "2.0" || r.Method == "" {
		return &response{JSONRPC: "2.0", Error: &rpcError{-32600, "Invalid Request"}, ID: json.RawMessage("null")}
	}
	notification := len(r.ID) == 0
	h, ok := methods[r.Method]
	if !ok {
		if notification {
			return nil
		}
		return &response{JSONRPC: "2.0", Error: &rpcError{-32601, "Method not found"}, ID: r.ID}
	}
	res, err := h(r.Params)
	if notification {
		return nil // the caller said it does not want an answer, even for an error
	}
	switch {
	case errors.Is(err, errInvalidParams):
		return &response{JSONRPC: "2.0", Error: &rpcError{-32602, "Invalid params"}, ID: r.ID}
	case err != nil:
		return &response{JSONRPC: "2.0", Error: &rpcError{-32603, "Internal error"}, ID: r.ID} // never echo err.Error()
	}
	return &response{JSONRPC: "2.0", Result: res, ID: r.ID}
}

func serve(w http.ResponseWriter, r *http.Request) {
	body, _ := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	body = bytes.TrimSpace(body)
	w.Header().Set("Content-Type", "application/json")
	if !json.Valid(body) {
		json.NewEncoder(w).Encode(response{JSONRPC: "2.0", Error: &rpcError{-32700, "Parse error"}, ID: json.RawMessage("null")})
		return
	}
	if len(body) > 0 && body[0] == '[' { // a batch
		var items []json.RawMessage
		json.Unmarshal(body, &items)
		if len(items) == 0 {
			json.NewEncoder(w).Encode(response{JSONRPC: "2.0", Error: &rpcError{-32600, "Invalid Request"}, ID: json.RawMessage("null")})
			return
		}
		var out []*response
		for _, it := range items {
			if resp := call(it); resp != nil {
				out = append(out, resp)
			}
		}
		if len(out) == 0 {
			w.WriteHeader(http.StatusNoContent)
			return
		}
		json.NewEncoder(w).Encode(out)
		return
	}
	resp := call(body)
	if resp == nil {
		w.WriteHeader(http.StatusNoContent)
		return
	}
	json.NewEncoder(w).Encode(resp)
}

func main() {
	srv := httptest.NewServer(http.HandlerFunc(serve))
	defer srv.Close()

	send := func(label, payload string) {
		resp, _ := http.Post(srv.URL, "application/json", strings.NewReader(payload))
		b, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		fmt.Printf("%s\n  -> %d %s\n", label, resp.StatusCode, strings.TrimSpace(string(b)))
	}
	send("call", `{"jsonrpc":"2.0","method":"math.add","params":{"a":2,"b":3},"id":1}`)
	send("string id", `{"jsonrpc":"2.0","method":"ping","id":"req-7"}`)
	send("notification", `{"jsonrpc":"2.0","method":"ping"}`)
	send("unknown method", `{"jsonrpc":"2.0","method":"nope","id":2}`)
	send("bad params", `{"jsonrpc":"2.0","method":"math.add","params":"x","id":3}`)
	send("handler failure", `{"jsonrpc":"2.0","method":"boom","id":4}`)
	send("not JSON", `{"jsonrpc":"2.0","method":`)
	send("not JSON-RPC", `{"hello":"world"}`)
	send("batch", `[{"jsonrpc":"2.0","method":"ping","id":1},{"jsonrpc":"2.0","method":"ping"},{"jsonrpc":"2.0","method":"math.add","params":{"a":1,"b":1},"id":2}]`)
	send("empty batch", `[]`)
}
Text
call
  -> 200 {"jsonrpc":"2.0","result":5,"id":1}
string id
  -> 200 {"jsonrpc":"2.0","result":"pong","id":"req-7"}
notification
  -> 204 
unknown method
  -> 200 {"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":2}
bad params
  -> 200 {"jsonrpc":"2.0","error":{"code":-32602,"message":"Invalid params"},"id":3}
handler failure
  -> 200 {"jsonrpc":"2.0","error":{"code":-32603,"message":"Internal error"},"id":4}
not JSON
  -> 200 {"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error"},"id":null}
not JSON-RPC
  -> 200 {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request"},"id":null}
batch
  -> 200 [{"jsonrpc":"2.0","result":"pong","id":1},{"jsonrpc":"2.0","result":2,"id":2}]
empty batch
  -> 200 {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request"},"id":null}

The structure is: serve handles the transport and the batch envelope, call handles one request, and the methods map is the router. Adding an endpoint means adding a function to the map. Each handler decodes its own parameters and returns a value or an error.

Reading the output

The plain call returns {"result":5,"id":1}. The string id "req-7" comes back unchanged, which is the point of echoing the raw bytes instead of parsing into an int.

The notification returns HTTP 204 with an empty body. Over HTTP, "no reply" has to be spelled as something, and 204 is the honest option. Over a socket there is no write.

Each of the failures has its own code. An unknown method is -32601. A body that is a JSON string where an object was expected fails decoding in the handler and becomes -32602. The handler that returns a Go error produces -32603 Internal error, and the client does not see database unavailable. Echoing internal error text is a habit worth breaking in any API, and in this one it would be easy because err.Error() is right there.

The malformed body returns -32700 with "id": null, because there is no id to echo. The {"hello":"world"} body is valid JSON but not a request, so it gets -32600.

The batch of three requests contained one notification. The reply is an array of two. The client matches the results by id, not by position, since the spec allows any order.

HTTP status codes

JSON-RPC over HTTP is awkward at exactly one point: status codes. The spec is transport-neutral and does not tell you what to put in the HTTP status, so the common convention is 200 for anything that produced a JSON-RPC response, including errors, and a non-200 only when the HTTP layer itself failed: 401 for missing credentials, 413 for a body that is too large, 429 for rate limiting, 5xx for a proxy problem.

That has a monitoring cost similar to GraphQL's. A dashboard that watches status codes will show a healthy service while every call returns an error. Log the method name and error code of every call and graph those. The GraphQL or REST comparison makes the same point at more length.

Some servers map error classes to HTTP statuses (400 for parse errors, 404 for an unknown method, 500 for internal errors). Clients written for plain JSON-RPC may not expect that, so only do it if you control the clients.

Where JSON-RPC is the better fit

Command-style APIs. When the operations do not map to nouns, naming a method is clearer than inventing a resource. cluster.rebalance says more than POST /rebalances.

Batching. The spec has batching built in. A client can send twenty calls in one round trip and get twenty answers. With REST you invent a batch endpoint, as in the bulk endpoints article.

Persistent connections in both directions. Over a WebSocket or stdio, JSON-RPC gives you request and response correlation for free, and since a server can also send requests and notifications to the client, it supports two-way calls. That is why language servers and many plugin systems use it. The WebSocket article covers the connection side.

Internal tools. When you control both ends and want less ceremony than a code-generated gRPC stack, a small JSON-RPC layer is easy to debug with curl and a text editor.

Where it is the wrong fit

Public, cacheable reads. There are no URLs to cache. Everything is a POST to one endpoint, so CDNs and browser caches do nothing for you.

Resource-heavy CRUD. If most of your API is create, read, update, delete over nouns, REST gives you the uniform interface, status codes and tooling for free.

A need for strict typing. The spec says nothing about parameter schemas. You get what you write in the handlers. OpenRPC exists as a description format, but it has far less tooling than OpenAPI or protobuf.

Rate limiting and authorisation by route. Every call shares a URL, so middleware cannot distinguish user.get from user.delete. The method name is in the body, and authorisation has to look inside it. That is a place for a table of method to required permission, checked before dispatch.

Practical details

Limit the body. The lab uses http.MaxBytesReader at 1 MiB. Cap batch length too, since a batch of 100,000 calls is a denial of service in one request.

Decide batch concurrency. The lab runs batch members one after another. The spec allows parallel processing, and running them concurrently is faster, but then the handlers must not rely on order. State which one you guarantee.

Use the standard library carefully. Go has net/rpc/jsonrpc, but it speaks an older JSON-RPC 1.0 style framing over a connection, and the net/rpc package is frozen. It is fine for a quick internal tool between two Go programs and not what you want for a public JSON-RPC 2.0 API. The net/rpc documentation says so itself.

Version at the method level. Add v2 to a method name, or a version in the namespace, when a method changes shape. The versioning article covers the general rules.

The full text of the protocol is the JSON-RPC 2.0 specification, about three pages. Read it once; the examples at the end cover every edge case that the dispatcher above handles.

Taggedjson
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.