How does OpenAPILink performance compare to RPCLink?
compare OpenAPILink vs RPCLink in terms of performance.
Now I have a complete picture. Let me summarize the performance comparison between OpenAPILink and RPCLink.
##Performance Comparison: OpenAPILink vs 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
| Aspect | RPCLink | OpenAPILink |
|---|---|---|
| Base Class | StandardRPCLink → StandardLink | StandardOpenAPILink → StandardLink |
| Protocol | Custom RPC Protocol | OpenAPI Specification (REST) |
| Package | @orpc/client | @orpc/openapi-client |
| Requires Contract | No | Yes |
###1. Serialization Performance
####RPCLink (RPC JSON Serializer)
- 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[]]
####OpenAPILink (OpenAPI JSON Serializer)
- Lossy serialization - types are converted to JSON-compatible primitives:
BigInt→stringDate→ ISO string (ornullif invalid)Set→ArrayMap→Array<[key, value]>NaN→null
- 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
JsonifiedClienttype wrapper)
###2. Encoding Complexity
####RPCLink Codec
The StandardRPCLinkCodec is straightforward:
- Build URL:
baseUrl + toHttpPath(path) - For GET: append
?data=JSON.stringify(serialized)if URL length allows - For POST: send serialized body directly
// Simple path construction
url.pathname = `${url.pathname.replace(/\/$/, '')}${toHttpPath(path)}`
####OpenAPILink Codec
The StandardOpenapiLinkCodec is more complex:
- Contract lookup required to determine:
- HTTP method from contract
- Input structure (compact vs detailed)
- Output structure (compact vs detailed)
- Dynamic path parameters extraction and interpolation
- Bracket notation serialization for query strings and form data
- Handles
params,query,body,headersseparately 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
- Contract traversal (
###3. Bracket Notation Overhead (OpenAPILink only)
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
| Scenario | RPCLink | OpenAPILink |
|---|---|---|
| Simple JSON | Similar | Similar |
| With metadata types (Date, BigInt, etc.) | Larger (includes meta array) | Smaller (but loses types) |
| With blobs | FormData with indexed blobs | FormData with bracket notation paths |
###5. Request Flexibility
####RPCLink
- Default: POST for everything
- Optional: GET with
?data=param (limited bymaxUrlLength, default 2083) - Falls back to POST if URL too long
####OpenAPILink
- HTTP method determined by contract
- GET requests use URLSearchParams (bracket notation)
- More REST-like, but less flexible at runtime
###Summary
| Factor | RPCLink | OpenAPILink |
|---|---|---|
| Serialization CPU | Slightly higher (metadata tracking) | Lower |
| Encoding CPU | Lower (simple path) | Higher (contract lookup, param interpolation) |
| Type Fidelity | ✅ Full round-trip | ❌ Lossy (needs JsonifiedClient) |
| Payload Size | Larger for typed data | Smaller |
| Contract Required | No | Yes |
| REST Compatibility | No | Yes |
| Cacheability | Manual GET setup | Built-in via contract |
###Recommendation
-
Use
RPCLinkwhen:- 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
OpenAPILinkwhen:- 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.
what about the lazy() router loading (used for code splitting in next.js): usable in both?
Now I have the complete picture. Let me provide a clear answer:
##Lazy Router Compatibility: RPCLink vs OpenAPILink
###Short Answer
| Link | Lazy Router Support |
|---|---|
| RPCLink | ✅ Fully supported - works transparently |
| OpenAPILink | ⚠️ Not supported - requires the contract upfront |
###Why RPCLink Works with Lazy Routers
RPCLink doesn't need any contract or router knowledge at runtime. It simply:
- Takes the procedure path (e.g.,
['planet', 'list']) - Converts it to an HTTP path:
/planet/list - 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.
###Why OpenAPILink Doesn't Work with Lazy Routers
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.
###Solution for OpenAPILink with Lazy Routers
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
| Scenario | Recommended Link |
|---|---|
| Server-side code splitting with lazy routers | RPCLink ✅ |
| Client needs OpenAPI/REST semantics | OpenAPILink (but unlazy the contract first) |
| Full lazy router support on both ends | RPCLink ✅ |
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.
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?
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:
###Runtime: RPCHandler + RPCLink
- ✅ 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.jsonspec 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:
- Define your router with lazy loading for runtime performance
- 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
| Concern | Solution |
|---|---|
| Runtime performance | RPCHandler/RPCLink with lazy routers |
| Type safety | Full TypeScript inference from router |
| API documentation | OpenAPI spec generated at build time |
| Third-party clients | OpenAPI spec for Postman, curl, etc. |
| Cold starts | Lazy loading - only load what's needed |
###When You'd Still Need OpenAPILink
- 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.
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?
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:
| Aspect | RPCLink | OpenAPILink |
|---|---|---|
| URL pattern | POST /rpc/users/find | GET /users/{id} |
| HTTP method | Mostly POST (GET optional) | Determined by contract (GET, POST, PUT, DELETE, etc.) |
| Path params | None - data in body/query | Yes - /users/123 |
| REST semantics | No | Yes |
###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.
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?
Exactly right! I overcomplicated it.
If you have the OpenAPI spec, non-oRPC clients can:
- Use raw fetch - the spec documents the endpoints
- Generate an SDK with tools like hey-api, openapi-typescript, orval, etc.
- 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 Handler | What the spec documents | What clients call |
|---|---|---|
| RPCHandler | POST /rpc/users/find, POST /rpc/users/create, etc. | RPC-style endpoints |
| OpenAPIHandler | GET /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}.
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?
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:
- Serialize
Dateas ISO string - Add metadata:
[1, "created_at"]means "pathcreated_atis a Date (type 1)" - On response, parse the
metaarray 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 complexity | Raw fetch experience |
|---|---|
| Plain JSON (strings, numbers, booleans, arrays, objects) | Easy - just wrap in { json: ... } |
| With Date/BigInt/Set/Map | Annoying - need to handle meta |
| With files | More 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