the problem
in a multi‑tenant SaaS we needed a single source of truth for data models while keeping page loads under 200 ms on the first paint. the backend team pushed a tRPC router for internal services, but the frontend product demanded flexible queries for dashboards, so we introduced a thin GraphQL layer on top of the same types. the result was a tangled contract surface that threatened both type safety and cacheability.
the api contract shape
using zod together with tRPC lets us export the exact schema to the client:
import { z } from 'zod'
import { initTRPC } from '@trpc/server'
const t = initTRPC.create()
const userSchema = z.object({
id: z.string(),
name: z.string(),
role: z.enum(['admin', 'member'])
})
export const appRouter = t.router({
getUser: t.procedure.input(z.string()).output(userSchema).query(({ input }) => {
return db.user.findUnique({ where: { id: input } })
})
})
the same userSchema can be fed to a GraphQL generator, keeping the runtime contract identical. however, GraphQL resolvers introduce a per‑field resolver overhead that can break our sub‑200 ms budget if not carefully batched.
performance edge case
the biggest surprise came from cache invalidation. tRPC responses are fully serializable, so we can memoize them with react-query using the procedure name as the query key. GraphQL, on the other hand, relies on the client’s cache normalization, which becomes fragile when you version fields. a naïve approach of adding @deprecated fields caused stale data to linger in the cache, leading to UI flicker.
to mitigate this we introduced a versioned namespace on the GraphQL endpoint:
type Query {
v2_user(id: ID!): UserV2!
}
type UserV2 {
id: ID!
fullName: String!
role: Role!
}
clients now request v2_user explicitly, and the cache key includes the version string, making eviction deterministic. the trade‑off is a small bump in schema maintenance cost, but it preserves the DX advantage of a single source of truth.
lessons learned
- keep the type definition source single –
zod+ codegen bridges tRPC and GraphQL. - isolate versioned GraphQL fields to avoid cache churn.
- profile per‑field resolver cost; batch where possible with DataLoader.
- document the cache key strategy for any future consumer.
how do you balance type safety and caching when exposing a versioned API in your stack
Top comments (1)
Nice write-up. One thing I'd add: react-query and the normalized client cache only help the second render for one user, while your sub-200 ms first paint is decided on the server. A shared cache in front of the origin helps more there, but a classic CDN only sees POST /graphql and caches nothing, so you need persisted queries over GET or a GraphQL-aware edge cache. If that cache tags responses by entity (User:42) and mutations purge those tags, eviction follows the data instead of the schema version. And for the versioning case, you can give the field a static tag like user-v2 and purge everything cached for that version with one call when you retire it.
For multi-tenant, take the tenant for the cache key from a verified JWT claim, never from a client header.
(Disclosure: I'm running a GraphQL edge cache that works this way, GraphPilot.)