Skip to content

IRPC Comparison

This page compares IRPC with other popular API patterns to help you choose the right tool for your project.

IRPC vs REST

AspectIRPCREST
BoilerplateZero - just declare functionsHigh - routes, controllers, serialization
Type SafetyEnd-to-end TypeScriptManual type definitions
Performance6.96x faster (batching)1x baseline
HTTP Requests10x fewer (automatic batching)One per call
Learning CurveMinimal - just functionsModerate - HTTP verbs, status codes
CachingBuilt-in per-functionManual implementation
Error HandlingAutomatic retry & timeout (configurable per function)Manual implementation

REST Example

typescript
// Define route
app.post('/api/users', async (req, res) => {
  const data = req.body;
  const user = await db.users.create(data);
  res.json(user);
});

// Client call
const response = await fetch('/api/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'John', email: 'john@example.com' }),
});
const user = await response.json();

IRPC Example

typescript
// 1. Shared (Client & Server)
// src/shared/rpc.ts
export const createUser = irpc.declare<CreateUserFn>('createUser', () => ({}));
typescript
// 2. Server Implementation
// src/server/rpc.ts
import { createUser } from '../shared/rpc.js';

irpc.construct(createUser, async (data) => {
  return await db.users.create(data);
});
typescript
// 3. Client Usage
// src/client/app.ts
import { createUser } from '../shared/rpc.js';

const user = await createUser({ name: 'John', email: 'john@example.com' });

Result: IRPC eliminates routes, manual serialization, and HTTP boilerplate.

IRPC vs gRPC

AspectIRPCgRPC
Setup ComplexitySimple - no code generationComplex - protobuf compilation
JavaScript ErgonomicsNative - just TypeScriptForeign - proto files
Browser SupportNative fetch APIRequires gRPC-web proxy
Type SafetyTypeScript nativeGenerated types
Performance6.96x faster than RESTSimilar to IRPC
StreamingIntegrated Streams (HTTP/SSE, WebSocket, Broadcast)Bidirectional streaming
BatchingAutomaticManual

gRPC Example

protobuf
// user.proto
syntax = "proto3";

service UserService {
  rpc CreateUser (CreateUserRequest) returns (User);
}

message CreateUserRequest {
  string name = 1;
  string email = 2;
}

message User {
  string id = 1;
  string name = 2;
  string email = 3;
}
typescript
// Generated code required
const client = new UserServiceClient('localhost:50051');
const user = await client.createUser({ name: 'John', email: 'john@example.com' });

IRPC Example

typescript
// 1. Shared (Client & Server)
export const createUser = irpc.declare<CreateUserFn>('createUser', () => ({}));

// 2. Client Usage
// No proto files, no code generation
const user = await createUser({ name: 'John', email: 'john@example.com' });

Result: IRPC provides gRPC-like performance without the complexity.

IRPC vs tRPC

AspectIRPCtRPC
Execution ModelUnified function callsFragmented (Queries, Mutations, Subscriptions)
Transport FlexibilityAny transport (HTTP, WebSocket, Broadcast)HTTP, WebSocket (via separate subscriptions)
BatchingAutomaticOpt-in
SetupPackage + transportRouter + client
Streaming / SubscriptionsIdentical signature, identical transportSeparate procedure, dedicated WS transport
HooksTwo-level (router + per-function)Procedure-level
CachingBuilt-in per-functionClient-side (manual or via React Query)

tRPC maps closely to REST and HTTP verbs, forcing you to classify every endpoint as a .query(), .mutation(), or .subscription(). IRPC treats the network purely as a remote execution layer — you don't classify HTTP intents, you just call standard isomorphic functions.

tRPC Example

typescript
// Define router (Forces classification)
const appRouter = router({
  getUser: procedure
    .input(z.string())
    .query(async ({ input }) => { ... }),
  createUser: procedure
    .input(z.object({ name: z.string(), email: z.string().email() }))
    .mutation(async ({ input }) => { ... }),
});

// Client call (Requires matching route execution type)
const user = await trpc.getUser.query('123');
const newUser = await trpc.createUser.mutate({ 
  name: 'John', 
  email: 'john@example.com' 
});

IRPC Example

typescript
// Declare unified functions
const getUser = irpc.declare<GetUserFn>('getUser', () => ({}));
const createUser = irpc.declare<CreateUserFn>('createUser', () => ({}));

// Client call (Identical invocation regardless of read/write classification)
const user = await getUser('123');
const newUser = await createUser({ name: 'John', email: 'john@example.com' });

Streaming Comparison

tRPC separates subscriptions from standard procedures:

typescript
// tRPC Backend: Different mental model for subscriptions
const appRouter = router({
  onDashboard: subscription(({ input }) => {
    return observable((emit) => {
      // Completely different API from queries/mutations
    });
  }),
});

// tRPC Frontend: Requires explicit event listener hooks and manual state management.
// Cleanup is hidden but tightly coupled strictly to React's hook lifecycle.
function DashboardWidget({ userId }) {
  const [data, setData] = useState(null);
  
  trpc.onDashboard.useSubscription(userId, {
    onData(event) {
      setData(event.data);
    },
    onError(err) {
      console.error(err);
    }
  });

  return <DashboardUI data={data} />;
}

IRPC uses the same function signature for both:

typescript
// IRPC: Identical declare/construct syntax
const getDashboard = irpc.declare<GetDashboardFn>('getDashboard', () => ({}));

// Client: Binds to standard UI component proxies without WebSocket logic.
// The network stream is dropped automatically on unmount.
// This identical logic works in React, Vue, Svelte, or Solid.
const DashboardWidget = setup(({ userId }) => {
  const dashboard = getDashboard(userId);

  return render(() => <DashboardUI data={dashboard.data} />);
});

IRPC vs GraphQL

AspectIRPCGraphQL
Query ComplexitySimple function callsComplex query language
Type GenerationNative TypeScriptCode generation required
CachingPer-function, simpleNormalized cache, complex
Over-fetchingNo - exact function returnsNo - query what you need
Under-fetchingBatching handles multiple callsSingle query for nested data
SubscriptionsBuilt-in via identical signature (HTTP/SSE or WS)Requires separate WebSocket infrastructure
Learning CurveMinimal - just functionsSteep - schema, resolvers, queries
Performance6.96x faster than RESTSimilar to REST
N+1 ProblemNo - batchingRequires DataLoader

GraphQL Example

graphql
# Schema definition
type User {
  id: ID!
  name: String!
  email: String!
}

type Mutation {
  createUser(name: String!, email: String!): User!
}
typescript
// Resolver implementation
const resolvers = {
  Mutation: {
    createUser: async (_, { name, email }) => {
      return await db.users.create({ name, email });
    },
  },
};

// Client call
const { data } = await client.mutate({
  mutation: gql`
    mutation CreateUser($name: String!, $email: String!) {
      createUser(name: $name, email: $email) {
        id
        name
        email
      }
    }
  `,
  variables: { name: 'John', email: 'john@example.com' },
});

IRPC Example

typescript
// Declare function
const createUser = irpc.declare<CreateUserFn>('createUser', () => ({}));

// Client call
const user = await createUser({ name: 'John', email: 'john@example.com' });

Result: IRPC provides the same data aggregation capabilities as GraphQL without learning a query language. For real-time data, IRPC streams over any transport — GraphQL requires separate WebSocket infrastructure and a completely different subscription schema definition.

Streaming Comparison

GraphQL forces you to define a separate subscription schema, configure a distinct WebSocket link on the client, and use separate hooks:

graphql
# GraphQL Schema: Separate subscription root
type Subscription {
  dashboardUpdated(userId: ID!): DashboardData!
}
typescript
// GraphQL Client: Requires separate WS Link and dedicated hook.
// Cleanup is hidden but permanently couples the architecture to React Apollo.
import { useSubscription } from '@apollo/client';

function DashboardWidget({ userId }) {
  const { data, loading } = useSubscription(DASHBOARD_SUBSCRIPTION, {
    variables: { userId }
  });

  if (loading) return <Loading />;
  return <DashboardUI data={data.dashboardUpdated} />;
}

IRPC treats streams exactly identically to standard asynchronous functions:

typescript
// IRPC: Identical declare/construct syntax
const getDashboard = irpc.declare<GetDashboardFn>('getDashboard', () => ({}));

// Client: Binds to standard UI component proxies without WebSocket logic.
// Because the proxy is framework-agnostic, the network stream is dropped automatically on unmount.
// This identical logic works in React, Vue, Svelte, or Solid.
const DashboardWidget = setup(({ userId }) => {
  const dashboard = getDashboard(userId);

  return render(() => <DashboardUI data={dashboard.data} />);
});

Performance Benchmark

Scenario: 100,000 users, 10 calls each (1,000,000 total calls)

FrameworkTotal TimeHTTP RequestsSpeedup
IRPC3,617ms100,0006.96x
Bun Native25,180ms1,000,0001.00x
Hono18,004ms1,000,0001.40x
Elysia36,993ms1,000,0000.68x

IRPC's automatic batching reduces HTTP overhead by 10x, resulting in 6.96x faster performance.

When to Use IRPC

Choose IRPC when:

  • ✅ You want type-safe remote calls without boilerplate
  • ✅ You need high performance with automatic batching
  • ✅ You prefer simple function calls over complex query languages
  • ✅ You want transport flexibility (HTTP, WebSocket, custom)
  • ✅ You're building a TypeScript/JavaScript application
  • ✅ You want built-in caching, retry, and timeout (configurable per function)

Choose REST when:

  • You need broad client compatibility (non-JavaScript)
  • You're building a public API with strict HTTP semantics
  • You have existing REST infrastructure

Choose gRPC when:

  • You need bidirectional streaming
  • You're in a polyglot microservices environment
  • You have strict performance requirements for internal services

Choose tRPC when:

  • You're building a React application with React Query
  • You want type safety without transport flexibility
  • You're okay with framework coupling

Choose GraphQL when:

  • You need flexible, client-driven queries
  • You have complex, nested data relationships
  • You want to expose a single endpoint for multiple clients

Migration Path

From REST to IRPC

  1. Replace route definitions with IRPC function declarations
  2. Convert controllers to handlers
  3. Remove manual serialization logic
  4. Update client fetch calls to function calls

From gRPC to IRPC

  1. Replace proto files with TypeScript types
  2. Convert service definitions to IRPC declarations
  3. Keep existing handler logic
  4. Remove protobuf compilation step

From tRPC to IRPC

  1. Replace router procedures with IRPC declarations
  2. Remove React Query dependency (if desired)
  3. Keep existing handler logic
  4. Update client calls to direct function calls

From GraphQL to IRPC

  1. Replace schema definitions with TypeScript types
  2. Convert resolvers to IRPC handlers
  3. Replace queries/mutations with function calls
  4. Remove GraphQL client dependency

Summary

IRPC combines the best of all worlds:

  • Simplicity of REST
  • Performance of gRPC
  • Type safety of tRPC
  • Flexibility without GraphQL complexity
  • Unified streaming without separate subscription infrastructure

Choose IRPC when you want high-performance, type-safe remote calls without the complexity of other solutions.