Back to blog
architecturerpcrestapi-design

JSON-RPC vs REST: Why Type-Safe APIs Win in Complex Applications

Ultimo Team7 min read

Every backend developer has an opinion about REST. Fewer have worked with JSON-RPC. And almost nobody has built a system that uses both in the same application — choosing the right paradigm per endpoint based on what the endpoint actually does.

This post compares REST and JSON-RPC honestly, explains where each breaks down, and shows how Ultimo's hybrid approach lets you use both — with generated TypeScript clients that make the distinction invisible to frontend developers.

What REST Gets Right

REST (Representational State Transfer) maps well to CRUD operations on resources:

GET    /users/42        → read user 42
POST   /users           → create a user
PUT    /users/42        → replace user 42
PATCH  /users/42        → partially update user 42
DELETE /users/42        → delete user 42

The mapping between HTTP verbs and database operations is intuitive. URLs represent resources. Status codes communicate outcome. Caching works naturally (GET responses are cacheable by default). API gateways, documentation tools, and monitoring systems all understand HTTP semantics.

For CRUD-heavy applications — content management systems, e-commerce catalogs, social media feeds — REST is the right choice. The resource model fits naturally and tools like OpenAPI generate documentation, client SDKs, and mock servers from your spec.

Where REST Breaks Down

REST's resource model becomes strained when operations don't map cleanly to "create, read, update, delete a noun":

1. Actions That Aren't CRUD

POST /users/42/send-verification-email
POST /orders/99/cancel
POST /portfolios/7/rebalance
POST /builds/trigger

These are actions (verbs), not resource modifications. REST forces you to model them as resource creation (POST /verification-emails) or sub-resource operations (POST /users/42/actions). Both feel like the API design is fighting the natural structure of the operation.

2. Complex Queries

GET /users?role=admin&created_after=2026-01-01&sort=name&include=posts,comments&page=2&limit=20

When a "read" requires multiple filter parameters, sorting, pagination, included relations, and field selection — the URL becomes unwieldy. You end up with custom query parameter conventions that each API implements differently.

3. Batch and Transactional Operations

REST has no built-in concept of "execute these three operations atomically." You either:

  • Make three separate HTTP requests (with partial failure risk)
  • Design a custom batch endpoint (POST /batch with a body array)
  • Nest the operations in a transaction resource

All three feel like workarounds.

4. Type Safety at the Boundary

REST endpoints return JSON. The shape of that JSON is documented externally (OpenAPI spec, README, Postman collection) but not enforced by the protocol. The client trusts that the server returns the documented shape. If the server changes the response format, the client breaks at runtime — not at compile time.

What JSON-RPC Offers

JSON-RPC 2.0 is a simple protocol for remote procedure calls over JSON:

// Request
{"jsonrpc": "2.0", "method": "getUser", "params": {"id": 42}, "id": 1}
 
// Response
{"jsonrpc": "2.0", "result": {"id": 42, "name": "Alice"}, "id": 1}

The model is straightforward:

  • Methods are named procedures (functions)
  • Params are the function arguments
  • Result is the return value
  • Errors are structured with codes and messages

RPC Naturally Maps to Function Calls

getUser({id: 42})
cancelOrder({orderId: 99, reason: "customer_request"})
rebalancePortfolio({portfolioId: 7, strategy: "target_weight"})
triggerBuild({branch: "main", environment: "staging"})

These read naturally as function calls — because they are. There is no pretense of resource modeling for operations that are inherently procedural.

Batching Is Built In

JSON-RPC supports request batching natively:

[
  {"jsonrpc": "2.0", "method": "getUser", "params": {"id": 1}, "id": 1},
  {"jsonrpc": "2.0", "method": "getUser", "params": {"id": 2}, "id": 2},
  {"jsonrpc": "2.0", "method": "getPortfolio", "params": {"userId": 1}, "id": 3}
]

The server processes all three and returns an array of responses. One HTTP round-trip, multiple operations, structured error handling per operation.

Type Safety With Code Generation

Because RPC methods have explicit input and output types, code generation produces type-safe clients naturally:

// Generated by Ultimo
const user = await client.getUser({ id: 42 });
//    ^? User — fully typed, IDE autocomplete works
 
const result = await client.cancelOrder({ orderId: 99, reason: "customer_request" });
//    ^? CancelResult — typed response

No URL construction. No response parsing. No manual type assertions. The function signature tells you exactly what goes in and what comes out.

Where JSON-RPC Falls Short

No HTTP Semantic Caching

HTTP caches understand GET requests and Cache-Control headers. JSON-RPC sends everything as POST to a single endpoint (/rpc). CDNs, browser caches, and reverse proxies cannot cache JSON-RPC responses without custom logic.

For publicly cacheable data (product catalogs, blog content, static configurations), REST's integration with HTTP caching infrastructure is a real advantage.

Tooling and Documentation

OpenAPI (Swagger) has massive ecosystem support — API gateways, documentation generators, mock servers, testing tools. JSON-RPC tooling exists but is smaller. If your API consumers expect Swagger UI or Postman collections, REST + OpenAPI is the path of least resistance.

Not Discoverable

A REST API's URL structure is (ideally) discoverable — you can guess that users live at /users and individual users at /users/:id. JSON-RPC methods are an opaque list until you read the documentation. There is no URL structure to explore.

Monitoring and Observability

API gateways and monitoring tools parse HTTP methods and paths to produce per-endpoint metrics. A JSON-RPC API appears as a single endpoint (POST /rpc) in most monitoring systems unless you instrument at the method level yourself.

Ultimo's Hybrid Approach

Ultimo takes the position that REST and JSON-RPC serve different purposes and both belong in the same application. You choose the paradigm per endpoint based on what it does:

use ultimo::prelude::*;
 
#[tokio::main]
async fn main() -> ultimo::Result<()> {
    let mut app = Ultimo::new();
    let mut rpc = RpcRegistry::new();
 
    // REST for resources — CRUD, cacheable, browseable
    app.get("/api/products/:id", get_product);
    app.get("/api/products", list_products);
    app.post("/api/products", create_product);
 
    // RPC for operations — procedures, typed, batch-friendly
    rpc.query("getPortfolioSummary", get_portfolio_summary);
    rpc.mutation("rebalancePortfolio", rebalance_portfolio);
    rpc.mutation("cancelOrder", cancel_order);
    rpc.query("calculateRisk", calculate_risk);
 
    app.rpc("/rpc", rpc);
    app.listen("127.0.0.1:3000").await
}

REST Endpoints Get OpenAPI

Ultimo generates OpenAPI 3.0 specifications from your REST routes, giving you Swagger UI, Postman imports, and API gateway integration:

app.get("/api/products/:id", get_product);
// → OpenAPI: GET /api/products/{id} with response schema

RPC Endpoints Get TypeScript Clients

RPC methods generate a fully typed TypeScript client:

#[derive(Serialize, Deserialize, TS)]
#[ts(export)]
pub struct PortfolioSummary {
    pub total_value: f64,
    pub positions: Vec<Position>,
    pub daily_pnl: f64,
}
 
rpc.query("getPortfolioSummary", |input: PortfolioQuery| async move {
    let summary = calculate_summary(&input).await?;
    Ok(summary)
});

Run ultimo generate and the TypeScript client appears:

// Generated — do not edit
export interface PortfolioSummary {
  total_value: number;
  positions: Position[];
  daily_pnl: number;
}
 
export const client = {
  getPortfolioSummary(input: PortfolioQuery): Promise<PortfolioSummary>,
  rebalancePortfolio(input: RebalanceInput): Promise<RebalanceResult>,
  cancelOrder(input: CancelInput): Promise<CancelResult>,
  calculateRisk(input: RiskInput): Promise<RiskAssessment>,
};

Frontend developers call typed functions. They never construct URLs, parse JSON manually, or wonder what shape the response will have.

The Type Safety Argument

The strongest case for RPC over REST is type safety at the API boundary.

In a REST API, the client makes assumptions about response shapes:

// REST — you hope this matches the actual response
const response = await fetch("/api/users/42");
const user = await response.json() as User; // Trust-based type assertion

If the server changes the response shape (adds a field, renames one, changes a type from string to number), the client breaks at runtime. The TypeScript compiler cannot help because the assertion is a lie — it tells the compiler "trust me, this is a User" without runtime validation.

In a generated RPC client, the types are derived from the server's Rust structs. If the server changes PortfolioSummary, the generated TypeScript client changes too. Any frontend code that doesn't match the new shape gets a compile error immediately — before deployment, before production, before users see it.

This is the same value proposition as GraphQL's type system or tRPC's inferred types — but without the GraphQL runtime overhead or the requirement that both client and server be TypeScript.

When to Use Each

Use REST when...Use JSON-RPC when...
The operation maps to CRUD on a resourceThe operation is a procedure (verb)
HTTP caching matters (public, CDN-friendly data)The response is user-specific and uncacheable
External consumers expect OpenAPI/SwaggerConsumers are your own TypeScript frontends
Discoverability of URL structure is valuableType-safe generated clients handle discovery
API gateways need per-path routing/metricsYou control the full stack
The operation is simple (fetch/create/update/delete)The operation involves complex inputs or batch processing

A Practical Example: E-Commerce

// REST — product catalog (cacheable, public, discoverable)
app.get("/api/products", list_products);
app.get("/api/products/:id", get_product);
app.get("/api/categories", list_categories);
 
// RPC — user-specific operations (typed, uncacheable, complex)
rpc.query("getCartSummary", get_cart_summary);
rpc.mutation("checkout", process_checkout);
rpc.mutation("applyDiscount", apply_discount);
rpc.query("getOrderHistory", get_order_history);
rpc.query("estimateShipping", estimate_shipping);

The product catalog is REST: it's cacheable, public, and indexed by search engines. Cart and checkout operations are RPC: they're user-specific, involve complex business logic, and benefit from typed client generation.

Migration Path

You don't need to rewrite your REST API to adopt RPC. Ultimo supports both simultaneously:

  1. Keep existing REST endpoints as-is
  2. Add new complex operations as RPC methods
  3. Generate TypeScript clients for the RPC surface
  4. Over time, migrate REST endpoints that struggle with the resource model to RPC

The frontend can use fetch for REST endpoints and the generated client for RPC methods — they coexist naturally.


Summary

REST is excellent for resource-oriented CRUD with HTTP caching and broad tooling support. JSON-RPC is excellent for typed procedure calls with complex inputs, batch operations, and generated clients. They are not competitors — they solve different problems.

Ultimo lets you use both in one application, with OpenAPI for REST and TypeScript codegen for RPC. Choose the right paradigm per endpoint and let the tooling handle the rest.

Related reading: