How does OpenAPILink performance compare to RPCLink?

HEAD
ff2168fchore: update dependencies and pm (#1380)
This post might have stale content, as HEAD is 11 commits ahead.

compare OpenAPILink vs RPCLink in terms of performance.

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

Now I have a complete picture. Let me summarize the performance comparison between OpenAPILink and RPCLink.


Both links extend StandardLink and share the same lifecycle, interceptor system, and client-server communication infrastructure. The key differences lie in their serialization, encoding, and protocol design.

###Architecture Overview

AspectRPCLinkOpenAPILink
Base ClassStandardRPCLinkStandardLinkStandardOpenAPILinkStandardLink
ProtocolCustom RPC ProtocolOpenAPI Specification (REST)
Package@orpc/client@orpc/openapi-client
Requires ContractNoYes

###1. Serialization Performance

  • Preserves type fidelity with metadata: BigInt, Date, NaN, undefined, URL, RegExp, Set, Map
  • Serializes data into: { json, meta, maps?, blobs? }
  • Uses a metadata array to track type transformations at each path
  • Deserialization walks the metadata to restore original types
// RPC serialization output structure
[json: unknown, meta: StandardRPCJsonSerializedMetaItem[], maps: Segment[][], blobs: Blob[]]
  • Lossy serialization - types are converted to JSON-compatible primitives:
    • BigIntstring
    • Date → ISO string (or null if invalid)
    • SetArray
    • MapArray<[key, value]>
    • NaNnull
  • Only tracks: [json: unknown, hasBlob: boolean]
  • No metadata overhead - simpler but loses type information
// OpenAPI serialization output structure
[json: unknown, hasBlob: boolean]

Performance implication:

  • RPCLink has slightly higher serialization overhead due to metadata tracking
  • OpenAPILink is simpler but loses round-trip type fidelity (requires JsonifiedClient type wrapper)

###2. Encoding Complexity

The StandardRPCLinkCodec is straightforward:

  1. Build URL: baseUrl + toHttpPath(path)
  2. For GET: append ?data=JSON.stringify(serialized) if URL length allows
  3. For POST: send serialized body directly
// Simple path construction
url.pathname = `${url.pathname.replace(/\/$/, '')}${toHttpPath(path)}`

The StandardOpenapiLinkCodec is more complex:

  1. Contract lookup required to determine:
    • HTTP method from contract
    • Input structure (compact vs detailed)
    • Output structure (compact vs detailed)
  2. Dynamic path parameters extraction and interpolation
  3. Bracket notation serialization for query strings and form data
  4. Handles params, query, body, headers separately in "detailed" mode
// Contract-aware encoding
const procedure = get(this.contract, path)
const inputStructure = fallbackContractConfig('defaultInputStructure', procedure['~orpc'].route.inputStructure)

Performance implication:

  • RPCLink has O(1) encoding complexity
  • OpenAPILink has additional overhead from:
    • Contract traversal (get(this.contract, path))
    • Dynamic parameter interpolation
    • Bracket notation serialization for complex data

OpenAPILink uses StandardBracketNotationSerializer for:

  • GET request query parameters
  • FormData with nested blobs

This involves recursive traversal and string path construction:

// Bracket notation output example
// Input: { user: { name: "Alice", tags: ["a", "b"] } }
// Output: [["user[name]", "Alice"], ["user[tags][0]", "a"], ["user[tags][1]", "b"]]

Performance implication: Additional CPU cost for nested objects in query strings.


###4. Payload Size

ScenarioRPCLinkOpenAPILink
Simple JSONSimilarSimilar
With metadata types (Date, BigInt, etc.)Larger (includes meta array)Smaller (but loses types)
With blobsFormData with indexed blobsFormData with bracket notation paths

###5. Request Flexibility

  • Default: POST for everything
  • Optional: GET with ?data= param (limited by maxUrlLength, default 2083)
  • Falls back to POST if URL too long
  • HTTP method determined by contract
  • GET requests use URLSearchParams (bracket notation)
  • More REST-like, but less flexible at runtime

###Summary

FactorRPCLinkOpenAPILink
Serialization CPUSlightly higher (metadata tracking)Lower
Encoding CPULower (simple path)Higher (contract lookup, param interpolation)
Type Fidelity✅ Full round-trip❌ Lossy (needs JsonifiedClient)
Payload SizeLarger for typed dataSmaller
Contract RequiredNoYes
REST CompatibilityNoYes
CacheabilityManual GET setupBuilt-in via contract

###Recommendation

  • Use RPCLink when:

    • You need full type fidelity (BigInt, Date, Set, Map, etc.)
    • You control both client and server
    • Performance is critical and you want simpler encoding
  • Use OpenAPILink when:

    • You need REST/OpenAPI compatibility
    • You're consuming third-party OpenAPI services
    • You want semantic HTTP methods and paths
    • You don't need complex JavaScript types round-tripped

For pure oRPC-to-oRPC communication, RPCLink is generally more performant due to simpler encoding logic, despite the metadata overhead for type preservation.

Avatar of julianbenegas
Julian Benegas
commented

what about the lazy() router loading (used for code splitting in next.js): usable in both?

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

Now I have the complete picture. Let me provide a clear answer:


###Short Answer

LinkLazy Router Support
RPCLinkFully supported - works transparently
OpenAPILink⚠️ Not supported - requires the contract upfront

RPCLink doesn't need any contract or router knowledge at runtime. It simply:

  1. Takes the procedure path (e.g., ['planet', 'list'])
  2. Converts it to an HTTP path: /planet/list
  3. Sends the serialized input
// From rpc-link-codec.ts
url.pathname = `${url.pathname.replace(/\/$/, '')}${toHttpPath(path)}`

The server-side handler (RPCHandler) is responsible for resolving lazy routers at request time. The client has no awareness of whether the router is lazy or not.


OpenAPILink requires the contract at construction time and accesses it synchronously during encoding:

// From openapi-link-codec.ts
async encode(path: readonly string[], input: unknown, options: ClientOptions<T>): Promise<StandardRequest> {
  // ...
  const procedure = get(this.contract, path)  // Synchronous access!

  if (!isContractProcedure(procedure)) {
    throw new Error(`[StandardOpenapiLinkCodec] expect a contract procedure at ${path.join('.')}`)
  }

  const inputStructure = fallbackContractConfig('defaultInputStructure', procedure['~orpc'].route.inputStructure)
  // ...
}

The get() function does a simple synchronous property traversal:

// From packages/shared/src/object.ts
export function get(object: unknown, path: readonly PropertyKey[]): unknown {
  let current: unknown = object

  for (const key of path) {
    if (!isTypescriptObject(current)) {
      return undefined
    }
    current = current[key]
  }

  return current
}

This cannot resolve lazy imports - it just returns undefined or the Lazy wrapper object, which isn't a valid contract procedure.


If you need to use OpenAPILink with a router that uses lazy loading, you must unlazy the router first:

import { unlazyRouter } from '@orpc/server'

// Server-side: resolve all lazy routers
const resolvedRouter = await unlazyRouter(router)

// Then export/minify for client use
const minifiedContract = minifyContractRouter(resolvedRouter)

From the docs (router-to-contract.md):

A normal router works as a contract router as long as it does not include a lazy router. If your router includes a lazy router, you need to fully resolve it to make it compatible with contract.


###Summary for Next.js Code Splitting

ScenarioRecommended Link
Server-side code splitting with lazy routersRPCLink
Client needs OpenAPI/REST semanticsOpenAPILink (but unlazy the contract first)
Full lazy router support on both endsRPCLink

For Next.js code splitting: Use RPCLink if you want your server-side lazy routers to remain lazy. The server's RPCHandler will resolve them on-demand, giving you the cold-start performance benefits.

Avatar of julianbenegas
Julian Benegas
commented

best of both worlds is to use RPCHandler / Link and then generate an openapi json spec as part of the build process? or not really?

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

Now I have the complete picture. Let me provide the answer:


##Yes, That's Exactly the Best of Both Worlds! ✅

Your intuition is correct. Here's the recommended architecture:

  • ✅ Full lazy router support for code splitting
  • ✅ Type fidelity (Date, BigInt, Set, Map, etc.)
  • ✅ Better cold-start performance
  • ✅ Simpler encoding/decoding

###Build Time: OpenAPIGenerator

  • ✅ Generate openapi.json spec for documentation (Scalar, Swagger UI)
  • ✅ Third-party integrations (Postman, external clients)
  • ✅ API reference for non-TypeScript consumers

###How It Works

The OpenAPIGenerator.generate() method resolves all lazy routers at generation time:

// From packages/server/src/router-utils.ts
export async function resolveContractProcedures(
  options: TraverseContractProceduresOptions,
  callback: (options: TraverseContractProcedureCallbackOptions) => void,
) {
  const pending: TraverseContractProceduresOptions[] = [options]

  for (const options of pending) {
    const lazyOptions = traverseContractProcedures(options, callback)

    for (const options of lazyOptions) {
      const { default: router } = await unlazy(options.router)  // <-- Resolves lazy!

      pending.push({
        router,
        path: options.path,
      })
    }
  }
}

This means you can:

  1. Define your router with lazy loading for runtime performance
  2. Generate the OpenAPI spec at build time which resolves all lazy imports

###Example Setup

// router.ts - Uses lazy loading for code splitting
const router = {
  ping,
  users: os.lazy(() => import('./users')),
  posts: os.lazy(() => import('./posts')),
}

// Server: RPCHandler (lazy routers resolved on-demand at runtime)
const handler = new RPCHandler(router)

// Client: RPCLink (no contract needed, works with lazy routers)
const link = new RPCLink({ url: '/rpc' })
const client = createORPCClient(link)
// scripts/generate-openapi.ts - Build-time script
import { OpenAPIGenerator } from '@orpc/openapi'
import { ZodToJsonSchemaConverter } from '@orpc/zod'
import fs from 'node:fs'
import { router } from '../src/router'

const generator = new OpenAPIGenerator({
  schemaConverters: [new ZodToJsonSchemaConverter()],
})

// This await resolves ALL lazy routers
const spec = await generator.generate(router, {
  info: { title: 'My API', version: '1.0.0' },
})

fs.writeFileSync('./public/openapi.json', JSON.stringify(spec, null, 2))
// package.json
{
  "scripts": {
    "build": "npm run generate:openapi && next build",
    "generate:openapi": "tsx scripts/generate-openapi.ts"
  }
}

###What You Get

ConcernSolution
Runtime performanceRPCHandler/RPCLink with lazy routers
Type safetyFull TypeScript inference from router
API documentationOpenAPI spec generated at build time
Third-party clientsOpenAPI spec for Postman, curl, etc.
Cold startsLazy loading - only load what's needed

  • Consuming a third-party OpenAPI API (not your own oRPC server)
  • Your backend isn't oRPC but follows OpenAPI spec
  • You need semantic REST paths (GET /users/{id}) at runtime for specific clients

But for most oRPC-to-oRPC setups, RPCLink + build-time OpenAPI generation is indeed the best of both worlds.

Avatar of julianbenegas
Julian Benegas
commented

You need semantic REST paths (GET /users/{id}) at runtime for specific clients

wdym? RPCLink doesn't do http calls at the end of the day?

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

You're right to call that out - I was unclear. Let me clarify:

Both RPCLink and OpenAPILink use HTTP/fetch under the hood. The difference is in the URL structure and HTTP semantics:

AspectRPCLinkOpenAPILink
URL patternPOST /rpc/users/findGET /users/{id}
HTTP methodMostly POST (GET optional)Determined by contract (GET, POST, PUT, DELETE, etc.)
Path paramsNone - data in body/queryYes - /users/123
REST semanticsNoYes

###What I meant:

If you have external consumers (mobile app team, third-party partners, etc.) who expect a traditional REST API like:

GET    /users/123
POST   /users
PUT    /users/123
DELETE /users/123

Then they'd need to hit an OpenAPIHandler endpoint, not RPCHandler. The RPCLink/RPCHandler combo gives you:

POST /rpc/users/find    { "id": 123 }
POST /rpc/users/create  { "name": "Alice" }
POST /rpc/users/update  { "id": 123, "name": "Bob" }
POST /rpc/users/delete  { "id": 123 }

###So the clarification is:

If all your clients are oRPC clients (using RPCLink), then RPCHandler + build-time OpenAPI spec is perfect.

If you need to serve REST-style endpoints to non-oRPC clients at runtime, you'd run OpenAPIHandler alongside (or instead of) RPCHandler - but your own oRPC clients could still use RPCLink for the benefits we discussed.

Avatar of julianbenegas
Julian Benegas
commented

If all your clients are oRPC clients

not really. but for non-oRPC clients, i have the OpenAPI json so they can "create their own sdk" potentially using just fetch or something like hey-api. isn't that right?

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

Exactly right! I overcomplicated it.

If you have the OpenAPI spec, non-oRPC clients can:

  1. Use raw fetch - the spec documents the endpoints
  2. Generate an SDK with tools like hey-api, openapi-typescript, orval, etc.
  3. Use Postman/Insomnia - import the spec directly

The question is: what endpoints does that spec document?


###The Key Point

The OpenAPI spec generated by OpenAPIGenerator documents whatever handler you're actually running:

Your HandlerWhat the spec documentsWhat clients call
RPCHandlerPOST /rpc/users/find, POST /rpc/users/create, etc.RPC-style endpoints
OpenAPIHandlerGET /users/{id}, POST /users, etc.REST-style endpoints

So if you run RPCHandler and generate the spec from it, non-oRPC clients would call:

# From the generated OpenAPI spec
curl -X POST https://api.example.com/rpc/users/find \
  -H "Content-Type: application/json" \
  -d '{"id": 123}'

That works perfectly fine! It's just not "RESTful" in the traditional sense - but who cares if it works and is documented.


###So yes, your setup is totally valid:

  • Runtime: RPCHandler + RPCLink (with lazy routers)
  • Build time: Generate OpenAPI spec
  • oRPC clients: Use RPCLink
  • Non-oRPC clients: Use the OpenAPI spec with fetch/hey-api/whatever

No need for OpenAPIHandler unless you specifically want REST semantics like GET /users/{id}.

Avatar of julianbenegas
Julian Benegas
commented

gotchu. but if i have an RPCHandler and say i wanna use just raw fetch, is it too annoying to comply with the RPCHandler contract? like does it add specific things that make it hard to work with without an RPCLink?

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

This answers your question clearly. Here's the verdict:


##It's a bit annoying, but not terrible

###Simple case (no special types):

# Pretty easy - just wrap in { json: ... }
curl -X POST https://example.com/rpc/users/find \
  -H 'Content-Type: application/json' \
  -d '{ "json": { "id": 123 } }'

Response:

{ "json": { "id": "123", "name": "Alice" } }

You just need to wrap/unwrap with { json: ... }. Not bad.


###Annoying case (with Date, BigInt, Set, etc.):

curl -X POST https://example.com/rpc/users/find \
  -d '{
    "json": {
      "name": "Earth",
      "created_at": "2022-01-01T00:00:00.000Z"
    },
    "meta": [[1, "created_at"]]
  }'

You have to:

  1. Serialize Date as ISO string
  2. Add metadata: [1, "created_at"] means "path created_at is a Date (type 1)"
  3. On response, parse the meta array to reconstruct types

###Even more annoying (with files):

const form = new FormData()
form.set('data', JSON.stringify({
  json: { name: 'Earth', images: [{}, {}] },
  maps: [['images', 0], ['images', 1]]  // blob locations
}))
form.set('0', blob1)
form.set('1', blob2)

##Bottom line

Data complexityRaw fetch experience
Plain JSON (strings, numbers, booleans, arrays, objects)Easy - just wrap in { json: ... }
With Date/BigInt/Set/MapAnnoying - need to handle meta
With filesMore annoying - need maps + FormData

If your API mostly deals with plain JSON, it's quite usable. If you're heavy on native types, non-oRPC clients will need to implement the meta/maps logic (or you generate an SDK with hey-api that handles it).

Alternatively, if raw fetch simplicity matters for external consumers, that's a valid reason to use OpenAPIHandler instead - it's just plain JSON in/out with standard REST conventions.


END OF POST

How does OpenAPILink performance compare to RPCLink? — middleapi/orpc