Diagnosing Generic INTERNAL_SERVER_ERROR in tRPC: A Debugging Guide
Diagnose why tRPC clients receive INTERNAL_SERVER_ERROR instead of the intended TRPCError code, with ordered checks, fixes, and escalation criteria.
05 Aug 2026, 12:03 UTC

Recognizable condition
Your tRPC client receives an error with data.code === 'INTERNAL_SERVER_ERROR' and a generic message, but your resolver explicitly threw a TRPCError with a specific code such as 'BAD_REQUEST' or 'UNAUTHORIZED'. React Query or vanilla client hooks show the masked error, while the server console may show the original stack or nothing at all. The symptom is consistent: the server knows the real reason, but the client sees a catch-all failure.
Cause and diagnostic table
| Finding | What it looks like | Where to look |
|---|---|---|
Plain Error thrown instead of TRPCError | Client gets INTERNAL_SERVER_ERROR; server logs show the original message | Resolver source; server logs |
Custom errorFormatter drops fields | Code is correct but message or cause is missing | initTRPC.create config |
| Detached async work | Error never reaches client; appears as unhandled rejection | Server console; process warnings |
| Environment masking | Works in development, fails in production | Diff NODE_ENV responses |
| Middleware does not propagate | Timeout or generic error instead of intended code | Middleware next() calls |
| Transformer mismatch | Client-side parse failure that looks like a server error | Server and client transformer config |
Ordered checks
Run these in order. Each step narrows the cause without changing production behavior.
1. Confirm the error type at the source
Check the resolver. If it throws new Error('...'), tRPC converts it to INTERNAL_SERVER_ERROR. Only TRPCError instances carry a code through the pipeline. Replace with throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid input' }). Verify the import matches your major version: v10 and v11 expose error utilities differently, and a v10 snippet may not compile on v11.
2. Inspect the errorFormatter
If you pass errorFormatter to initTRPC.create, it is the supported hook for shaping the client-visible error. A formatter that returns only the default shape drops data.cause and any custom fields. Add a temporary log inside the formatter to confirm it runs and what it receives:
errorFormatter: ({ error, shape }) => {
console.error('FORMATTER', error.code, error.cause);
return shape;
}
Run this in a local environment only. Do not log error.cause or request payloads in production; the formatter runs server-side and can leak tokens or PII.
3. Check for detached async work
Errors thrown in setTimeout, fire-and-forget promises, or background tasks outside the resolver's returned promise bypass tRPC's request pipeline. They never reach errorFormatter and appear only as unhandled rejections. Ensure all async work that can fail is awaited or returned from the resolver, or wrapped in a promise that the resolver awaits.
4. Diff development and production
tRPC intentionally reduces detail for non-TRPCError failures depending on environment. Start the server with NODE_ENV=development and again with NODE_ENV=production, then issue the same client request. If the code and message differ, environment masking is active. A green test suite in development does not prove the production client receives the intended code.
5. Verify middleware propagation
Middleware must call next({ ctx }) and let errors propagate. If middleware fails to call next() or swallows its rejection, the request may surface as a timeout or generic client error. Check every middleware in the chain for an unconditional next() call and no empty catch blocks.
6. Audit the data transformer
A missing or mismatched transformer such as superjson can corrupt serialized error payloads. The client may fail to deserialize and report a transport error that looks like a server failure. Confirm the same transformer is configured on both @trpc/server and @trpc/client. If you add fields in errorFormatter, ensure the transformer can serialize them.
7. Detect duplicate tRPC versions
In a monorepo, duplicate or mismatched tRPC versions across packages can cause subtle shape mismatches. Run the following in the repository root:
npm ls @trpc/server @trpc/client
Expected check: a single resolved version per package. If you see invalid or multiple versions, deduplicate. Required permission: read access to node_modules and lockfiles. Risk: changing versions may alter behavior; pin the major version and review release notes.
Fixes tied to findings
- Plain Error: Throw
TRPCErrorwith an explicitcode. If you must wrap an unknown error, usenew TRPCError({ code: 'INTERNAL_SERVER_ERROR', cause: originalError })so the cause is preserved server-side. - Formatter drops data: Return
shapeplus any additional fields, but keep the shape compatible with the client transformer. Example:return { ...shape, data: { ...shape.data, code: error.code, httpStatus: error.code === 'UNAUTHORIZED' ? 401 : 500 } }. Verify the client can readerror.data.code. - Detached work: Move the work inside the resolver and await it, or catch it and rethrow as
TRPCErrorbefore the resolver returns. - Environment masking: Do not rely on
NODE_ENV=developmentfor integration tests. Test with the production setting or mock the masking behavior. - Middleware: Ensure
next()is always called. If middleware catches an error, rethrow it or convert it toTRPCErrorbefore callingnext(). - Transformer: Align transformers on both ends. If using
superjson, import it identically on server and client.
Escalation criteria
Escalate to a maintainer or file an issue when:
- The resolver throws
TRPCError, the formatter logs the correct code, the transformer matches, and the client still receivesINTERNAL_SERVER_ERRORin a clean production build. - Duplicate versions are eliminated but the shape mismatch persists.
- The behavior differs between tRPC minor versions within the same major in a way not documented in the migration guide.
Before escalating, capture: the exact tRPC versions from npm ls, the errorFormatter source, the resolver snippet, and a diff of development versus production responses.
Limitations and verification
This guide assumes a standard tRPC setup with React Query or a vanilla client. It does not cover WebSocket link error handling or edge runtime constraints, which may add their own serialization limits. Version differences between v10 and v11 affect initTRPC usage, error-code exports, and Next.js integration packages; confirm your version before applying snippets.
To verify a fix, create a temporary procedure that throws new TRPCError({ code: 'BAD_REQUEST', message: 'boom' }) and assert the client hook exposes error.data.code === 'BAD_REQUEST' and error.message === 'boom'. Run the assertion under both NODE_ENV=development and NODE_ENV=production. If the production assertion fails while development passes, masking or transformer serialization is still active.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.