tRPC vs @imqueue: client–server types vs service–service RPC
tRPC gives you end-to-end types between a frontend and its backend. @imqueue gives you typed RPC between backend services over a queue. They solve adjacent — not competing — problems.
tRPC and @imqueue are both typed RPC for TypeScript, but they type different boundaries. tRPC infers types across a shared TypeScript project, which suits a frontend calling its own backend over HTTP. @imqueue generates a client from a running service, which suits backend services calling each other over a message queue. People sometimes ask whether @imqueue is "tRPC for microservices" — it's a useful comparison, as long as you keep the two problems distinct.
(tRPC details reflect its documented behavior at the time of writing; check their docs for the current API surface.)
What tRPC is for
tRPC shines at the client–server boundary — typically a web frontend calling its own backend. You define routers and procedures on the server, and the client infers their types directly from the server's types through a shared TypeScript project. The transport is usually HTTP, with WebSocket support for subscriptions. The magic is that there's no build step: type inference does the work, as long as both sides share the same TypeScript types at compile time.
That last condition is the key constraint. tRPC's inference relies on the client being able to import type from the server — natural in a monorepo where frontend and backend compile together, and awkward or impossible across independently deployed, independently versioned services.
What @imqueue is for
@imqueue targets the service–service boundary: backend services calling each other, often deployed separately, over a message queue rather than HTTP. It doesn't rely on a shared compile-time project. Each service is self-describing at runtime, and a typed client is generated from the service's description. That generated client can live in a different repository, ship on its own schedule, and be regenerated when the service changes.
So the distinction is:
- tRPC: compile-time type inference across a shared project → ideal frontend ↔ backend.
- @imqueue: runtime self-description → generated typed client → ideal service ↔ service.
The same contract, side by side
The difference is clearest in code. With tRPC, the contract is a value on the server whose type the client imports:
// server/src/router.ts
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const appRouter = t.router({
userById: t.procedure
.input(z.string())
.query(({ input }) => getUser(input)),
});
export type AppRouter = typeof appRouter;
// client — reaches into the server's source for its types
import type { AppRouter } from '../../server/src/router';
const client = createTRPCClient<AppRouter>({ /* links… */ });
const user = await client.userById.query('42');
That relative import type is the whole story: it's why tRPC feels frictionless in a monorepo, and why it stops being an option once the two sides live in separate repositories on separate release cadences.
With @imqueue, the contract is the service class, and the doc-block is load-bearing — JSDoc is the only type source the generator reads:
import { IMQService, expose } from '@imqueue/rpc';
export class User extends IMQService {
/**
* Returns a user by id
*
* @param {string} id - user identifier
* @return {Promise<{ id: string; name: string } | null>}
*/
@expose()
public async get(id: string): Promise<{ id: string; name: string } | null> {
return { id, name: 'Jane Doe' };
}
}
You generate the client against the running service:
imq client generate User ./src/clients
and the caller — in any repo — consumes a normal typed class:
import { user } from './clients/index.js';
const client = new user.UserClient({ callTimeout: 5000 });
await client.start();
const found = await client.get('42'); // typed end to end
No import type reaching across a project boundary, because the types arrived as generated code.
What happens in tRPC and @imqueue when the contract changes
Contract change is where the two models genuinely diverge, and it's the question worth asking of your own architecture.
Change a procedure's input in tRPC and the frontend stops compiling immediately — same build, same tsc run. That tight loop is tRPC's best feature. It works because both sides are one compilation unit, which is also why it can't help you across a deployment boundary: if the backend ships independently, nothing recompiles the caller at the moment the contract moves.
Change an exposed method in @imqueue and nothing breaks until someone regenerates. Regenerate, and mismatches become compile errors in the caller's own build. That's a deliberate trade: you get a versionable artifact that survives independent deploys, at the cost of a step you have to remember. In practice the loop looks like this, straight from the CLI guide:
imq ctl start -s user -c # bring the service up and wait for readiness
imq client generate user ./src/clients
imq ctl stop -s user
Neither model detects a running peer drifting from the client you generated last month — that's what versioning your service contracts is for, in either world.
Transport and coupling
tRPC runs over HTTP, which is exactly right for reaching a backend from a browser. @imqueue runs over a message queue, which is right for internal traffic — no per-service addressing, competing-consumer load balancing, and natural back-pressure — but not something a browser talks to directly.
The queue also changes the delivery contract in a way HTTP doesn't. An HTTP call either returns, errors, or times out. A queue-based call is at-least-once: @imqueue may deliver the same message twice, so exposed methods should be idempotent. If you're used to reasoning about HTTP request/response, that's the one mental model you actually have to adjust — see what guaranteed delivery really costs.
Where tRPC is the better choice
Being honest about this is more useful than a feature count:
- A browser is one of the callers. Not a contest — the queue isn't reachable from a browser, and
@imqueuehas no story here. - You want zero build steps. tRPC's inference needs no generator, no artifact, and no running service.
@imqueueneeds the service up to generate against. - You need subscriptions or streaming. tRPC has WebSocket subscriptions;
@imqueueis request/response over a broker, with no streaming. - You want validation and typing in one place. tRPC composes with validators like zod so the runtime check and the static type come from one schema.
@imqueuevalidates argument count and types from JSDoc, but a schema validator is yours to add. - Your whole app is one deployable. If frontend and backend ship together forever, tRPC's constraint costs you nothing and its ergonomics are better.
Where @imqueue's model costs you
Equally honest, and worth knowing before you adopt it:
- JSDoc is mandatory and load-bearing. Missing type annotations degrade to
any, and the@paramcount must match real arity or calls fail withIMQ_RPC_INVALID_ARGS_COUNT. Consuming projects must compile withremoveComments: falseor the generator has nothing to read. - No rest or spread parameters on exposed methods — a documented limitation. Pass an array instead.
callTimeoutis unset by default. A call to a service that never answers stays pending forever. Set it explicitly; the docs recommend it for production.- Decorator order matters.
@expose()must sit innermost — closest to the method — when combined with@lock(),@cacheor@logged(), or argument validation and the generated signature break. - Node and TypeScript only, Redis only.
@imqueueis not polyglot and ships one transport. gRPC is the better answer for a mixed-language fleet.
Using tRPC and @imqueue together
tRPC and @imqueue coexist cleanly, and in most systems that's the right answer: tRPC (or plain HTTP, or GraphQL) at the edge for your client apps, @imqueue between the services behind it.
browser ──tRPC/HTTP──▶ API gateway ──@imqueue/rpc──▶ user, billing, search…
The gateway is the only process that speaks both. Everything behind it gets queue semantics — competing consumers, no service discovery, no load balancer — and everything in front of it gets the browser-friendly transport it needs. The tutorial builds exactly this shape with a GraphQL gateway, and swaps in a REST one later.
The one thing to decide deliberately is which tool owns service-to-service calls, so you aren't running two RPC mechanisms over the same traffic.
Quick comparison
| @imqueue | tRPC | |
|---|---|---|
| Primary boundary | Service ↔ service (backend) | Client ↔ server (often frontend ↔ backend) |
| Transport | Message queue (Redis) | HTTP / WebSocket |
| How types cross | Client generated from the running service | Compile-time inference via shared project |
| Artifact | Committed generated client | None — types are inferred |
| Deployment coupling | Independent repos, independent cadence | Best as one compilation unit |
| Breaks on change | At regeneration | Immediately, same build |
| Delivery | At-least-once; handlers idempotent | HTTP request/response |
| Streaming | No | WebSocket subscriptions |
| Load balancing | Competing consumers on the queue | Whatever fronts your HTTP server |
| Languages | Node.js / TypeScript | TypeScript |
How to choose
- Use tRPC to connect a TypeScript frontend to its backend with zero-boilerplate inferred types in a monorepo.
- Use @imqueue to connect backend services to each other with typed, queue-based RPC when they're deployed and versioned independently.
- Use both in the same architecture — they're solving different halves of the problem.
Frequently asked questions about tRPC and @imqueue
Is @imqueue a drop-in replacement for tRPC?
No. tRPC types the client–server boundary and speaks HTTP; @imqueue types the service–service boundary and speaks a queue. A browser can't talk to @imqueue at all.
Can I use tRPC between backend services?
You can, and people do — it's HTTP underneath. The friction shows up in coupling: the caller needs the callee's TypeScript types at compile time, so independently deployed services end up publishing a types package or living in one repo. That's the constraint @imqueue removes by generating a client instead.
Does @imqueue need a monorepo?
No — that's rather the point. The generated client is a committed artifact, so callers can live in separate repositories and regenerate on their own schedule.
Do I still need code generation with @imqueue?
Yes, one command against a running service: imq client generate <name> [path]. That's the trade for working across deployment boundaries. See why generated clients beat hand-written ones.
Which gives better type safety?
Both are strong, differently. tRPC's inference can't drift from the server because it is the server's types. @imqueue's generated client can drift until you regenerate — but it's a real artifact you can version, review and ship independently.
Can I use @imqueue with a GraphQL or REST gateway?
Yes, and it's the common shape: the gateway speaks HTTP outward and @imqueue inward. The tutorial builds both variants.
If the service-to-service half is what you're wrestling with, Getting Started shows the generated-client workflow end to end, and type-safe service communication in TypeScript covers the typing model in more depth. Shipping inside a closed-source product? See commercial licensing & support.
Read this page as plain markdown — no HTML, no navigation. For pasting into an LLM, or for an agent to fetch.
Building on @imqueue? The open-source packages live on GitHub and the docs at imqueue.org. Shipping inside a closed-source product? See commercial licensing & support.