gRPC Interceptors in Node.js: Auth, Logging, Error Handling, and Metrics Without Copy-Paste
Every gRPC handler in your Node.js service has the same auth check, the same log statement, the same error-to-status-code mapping. Here is the interceptor pattern that removes all of it and adds composable middleware to your gRPC stack in under 200 lines.
Open any realistic gRPC service in Node.js and you will find the same five things copy-pasted into every handler: authenticate the caller, log the request, start a timer, catch errors and map them to gRPC status codes, increment a metric on the way out. The handlers themselves are 30 lines, but the wrapping ceremony around each one is 60 lines of boilerplate that rots differently every time someone adds a new RPC.
gRPC has a solution for this: interceptors. They work like Express middleware but for gRPC — a chain of functions that wrap every unary, server-streaming, client-streaming, and bidirectional call. They are part of the Node.js gRPC library (both @grpc/grpc-js and the grpc package support them), yet most production codebases I audit still paste the boilerplate.
This post gives you the interceptor pattern that separates cross-cutting concerns from business logic: a chain that handles auth, structured logging, error mapping, and metrics in under 200 lines of TypeScript. Every piece is testable in isolation, every interceptor can be removed without touching handlers, and the pattern works for unary calls, streams, and client-side calls equally.
The problem in code
A typical unary handler without interceptors looks like this:
import { sendUnaryData, ServerUnaryCall } from '@grpc/grpc-js';
import { authenticate } from '../auth';
import { logger } from '../logging';
import { metrics } from '../metrics';
async function getOrder(
call: ServerUnaryCall<GetOrderRequest, GetOrderResponse>,
callback: sendUnaryData<GetOrderResponse>
) {
// boilerplate
const start = Date.now();
logger.info({ method: 'getOrder', requestId: call.metadata.get('x-request-id')[0] });
try {
const user = authenticate(call.metadata);
if (!user) {
callback({ code: grpc.status.UNAUTHENTICATED, message: 'invalid token' });
metrics.incr('grpc.getOrder.auth_error');
return;
}
// actual business logic
const order = await db.query('SELECT * FROM orders WHERE id = $1', [call.request.orderId]);
if (!order) {
callback({ code: grpc.status.NOT_FOUND, message: 'order not found' });
metrics.incr('grpc.getOrder.not_found');
return;
}
metrics.timing('grpc.getOrder.duration', Date.now() - start);
metrics.incr('grpc.getOrder.success');
callback(null, { order });
} catch (err) {
logger.error({ err, method: 'getOrder' });
callback({ code: grpc.status.INTERNAL, message: 'internal error' });
metrics.incr('grpc.getOrder.error');
}
}
Now multiply that by 15 RPCs. The business logic is the three lines in the middle. Everything else is copy-paste overhead that every engineer on the team modifies slightly differently, producing a slow leak of inconsistency that makes it impossible to change auth or add a new metric without touching every file.
What an interceptor actually is
A gRPC interceptor is a function that receives the call, the metadata, and a reference to the next interceptor or handler in the chain. It can inspect or mutate any of them, and it must call the next function or produce the final response.
For @grpc/grpc-js, the server interceptor signature is:
type ServerInterceptor = (
call: ServerUnaryCall<any, any> | ServerWritableStream<any, any> |
ServerReadableStream<any, any> | ServerDuplexStream<any, any>,
metadata: Metadata,
next: (call: any, metadata: Metadata) => void
) => void;
The interceptor calls next() to continue the chain (or not, if it wants to reject early). After next(), it can wrap the response handling by listening to events on the call object.
This is the key insight: interceptors surround the entire lifecycle of a call, not just the request path. You can run code before the handler, after the response is sent, and on errors, all from a single function.
The interceptor chain
Here is the interceptor chain that replaces the 60 lines of boilerplate per handler with five composable interceptors:
1. Auth interceptor
// interceptors/auth.ts
import { Metadata, ServerUnaryCall, status } from '@grpc/grpc-js';
export function authInterceptor(userExtractor: (meta: Metadata) => string | null) {
return (
call: ServerUnaryCall<any, any>,
metadata: Metadata,
next: (call: any, metadata: Metadata) => void
) => {
const userId = userExtractor(metadata);
if (!userId) {
call.sendError({ code: status.UNAUTHENTICATED, message: 'missing or invalid auth token' });
return;
}
// Attach the userId to metadata so downstream interceptors and
// the handler can read it without re-parsing the token.
metadata.add('x-authenticated-user', userId);
next(call, metadata);
};
}
The auth interceptor exits early with call.sendError if the token is missing or invalid. It never calls next, so the handler never runs. If the token is valid, it attaches the user ID to the metadata object and passes control to the next interceptor.
This is critical because of how gRPC metadata works: it is available in the handler via call.metadata, so any interceptor upstream can enrich it.
2. Structured logging interceptor
// interceptors/logging.ts
import { Metadata, ServerUnaryCall, ServerWritableStream,
ServerReadableStream, ServerDuplexStream, status } from '@grpc/grpc-js';
import { logger } from '../logger';
type ServerCall = ServerUnaryCall<any, any> | ServerWritableStream<any, any> |
ServerReadableStream<any, any> | ServerDuplexStream<any, any>;
export function loggingInterceptor(
call: ServerCall,
metadata: Metadata,
next: (call: ServerCall, metadata: Metadata) => void
) {
const requestId = metadata.get('x-request-id')[0] || 'unknown';
const method = call.getPath(); // e.g., "/orders.OrderService/GetOrder"
const userId = metadata.get('x-authenticated-user')[0] || 'anonymous';
logger.info({
msg: 'incoming request',
method,
requestId,
userId,
type: call.constructor.name,
});
const start = Date.now();
// Intercept the response by wrapping the sendError and listen for finish.
const originalSendError = call.sendError.bind(call);
call.sendError = ((error: any) => {
const duration = Date.now() - start;
logger.info({
msg: 'request failed',
method,
requestId,
duration,
errorCode: error?.code,
errorMessage: error?.details,
});
return originalSendError(error);
}) as typeof call.sendError;
call.on('finish', () => {
const duration = Date.now() - start;
logger.info({
msg: 'request completed',
method,
requestId,
duration,
});
});
next(call, metadata);
}
The logging interceptor wraps the lifecycle: log on entry, log on error (via sendError override), and log on success (via the finish event). Because it uses call.getPath() (not a hardcoded string), it works for every RPC in the service with zero per-handler configuration.
The sendError override is safe here because it runs before the handler gets control. Each interceptor can wrap sendError as long as they are stacked in the right order (outermost interceptor wraps first, and calls the original when done).
3. Error mapping interceptor
gRPC has a well-defined status code model. Your handlers should throw domain errors with meaningful codes, not INTERNAL for everything:
// interceptors/errors.ts
import { Metadata, ServerUnaryCall, status } from '@grpc/grpc-js';
export class GrpcDomainError extends Error {
constructor(
message: string,
public code: status
) {
super(message);
this.name = 'GrpcDomainError';
}
}
export function errorMappingInterceptor(
call: ServerUnaryCall<any, any>,
metadata: Metadata,
next: (call: ServerUnaryCall<any, any>, metadata: Metadata) => void
) {
// Override sendError to catch domain errors
const originalNext = next;
const wrappedNext = (call: any, metadata: Metadata) => {
try {
originalNext(call, metadata);
} catch (err) {
if (err instanceof GrpcDomainError) {
call.sendError({ code: err.code, message: err.message, details: err.message });
} else if (err && typeof err === 'object' && 'code' in err && typeof err.code === 'number') {
call.sendError({ code: err.code, message: (err as Error).message });
} else if (err instanceof Error) {
call.sendError({
code: status.INTERNAL,
message: 'internal error',
details: process.env.NODE_ENV === 'development' ? err.message : undefined,
});
} else {
call.sendError({ code: status.UNKNOWN, message: 'unknown error' });
}
}
};
next(call, metadata);
// Note: gRPC interceptors handle errors differently; for a cleaner
// approach, see the final composition below.
}
A cleaner approach wraps the call’s sendError and the handler execution together. The full composition in the next section shows the proper pattern.
4. Metrics interceptor
// interceptors/metrics.ts
import { Metadata, ServerUnaryCall } from '@grpc/grpc-js';
import { metrics } from '../metrics';
export function metricsInterceptor(
call: ServerUnaryCall<any, any>,
metadata: Metadata,
next: (call: ServerUnaryCall<any, any>, metadata: Metadata) => void
) {
const method = call.getPath();
const start = Date.now();
call.on('finish', () => {
const duration = Date.now() - start;
metrics.timing('grpc.handler.duration', duration, { method });
});
const originalSendError = call.sendError.bind(call);
call.sendError = ((error: any) => {
metrics.incr('grpc.handler.error', { method, code: String(error?.code ?? 'unknown') });
return originalSendError(error);
}) as typeof call.sendError;
metrics.incr('grpc.handler.started', { method });
next(call, metadata);
}
Each interceptor focusses on exactly one concern. None of them know about the other interceptors. None of them touch the handler. And every one of them works without a single change to the handler code.
Wiring interceptors together
Interceptors are registered at server creation time via the interceptors option:
// server.ts
import * as grpc from '@grpc/grpc-js';
import { authInterceptor } from './interceptors/auth';
import { loggingInterceptor } from './interceptors/logging';
import { metricsInterceptor } from './interceptors/metrics';
import { errorMappingInterceptor } from './interceptors/errors';
const server = new grpc.Server({
interceptors: [
// Order matters: the first interceptor listed wraps the outermost layer.
// Auth runs first, then logging, then metrics, then error mapping.
authInterceptor((meta) => {
const token = meta.get('authorization')[0] as string | undefined;
if (!token) return null;
return verifyToken(token.replace('Bearer ', ''));
}),
loggingInterceptor,
metricsInterceptor,
errorMappingInterceptor,
],
});
The interceptor chain for a single request now looks like this:
client -> auth -> logging -> metrics -> error-mapping -> handler -> error-mapping -> metrics -> logging -> client
Every interceptor runs on the way in and (via event listeners and sendError overrides) on the way out. The handler becomes a simple function with no cross-cutting concerns:
// handlers.ts
async function getOrder(
call: ServerUnaryCall<GetOrderRequest, GetOrderResponse>,
callback: sendUnaryData<GetOrderResponse>
) {
const userId = call.metadata.get('x-authenticated-user')[0];
const order = await db.query('SELECT * FROM orders WHERE id = $1', [call.request.orderId]);
if (!order) {
throw new GrpcDomainError('order not found', grpc.status.NOT_FOUND);
}
callback(null, { order });
}
The handler is nine lines. No auth parsing, no log statements, no metric increments, no try/catch for status code mapping. Every cross-cutting concern lives in exactly one file.
The three traps that break interceptor chains
Trap 1: Order inversion
If you put errorMappingInterceptor before loggingInterceptor, the error mapping runs before the logging interceptor sees the error. The logging interceptor logs the success (because next() returned without throwing), but the handler actually failed. The interceptor chain is a stack: the first interceptor listed wraps everything below it.
Fix: put error mapping, logging, and metrics AFTER auth in the stack. Auth rejects early (it controls the gate). Everything else wraps the output path.
Trap 2: Async error swallowing
gRPC interceptors in @grpc/grpc-js handle synchronous throws from next() but do not automatically catch rejected promises. If your handler is async and throws, the interceptor chain will not see the error. You must ensure that next() is called inside a try/catch (for sync errors) and that async handler errors are caught at the point the handler is invoked.
The safest pattern is to wrap next() with a promise-aware error boundary:
function safeNext(call: any, metadata: Metadata, next: Function) {
try {
const result = next(call, metadata);
if (result && typeof result.then === 'function') {
result.catch((err: any) => {
if (!call.writableEnded) {
call.sendError({ code: status.INTERNAL, message: err?.message ?? 'unexpected error' });
}
});
}
} catch (err) {
if (!call.writableEnded) {
call.sendError({ code: status.INTERNAL, message: (err as Error)?.message ?? 'unexpected error' });
}
}
}
This is not a hypothetical trap. I have seen interceptor chains that silently drop errors because an async handler rejects, nobody catches it, and the client hangs until the gRPC timeout fires. The client gets a DEADLINE_EXCEEDED instead of INTERNAL, and debugging takes hours.
Trap 3: Metadata mutation order
Multiple interceptors reading and writing to the same metadata object works as long as they are order-aware. The auth interceptor writes x-authenticated-user. The logging interceptor reads it. If you reorder them, the logging interceptor reads an empty metadata field.
The fix is a convention: document which interceptors write to metadata and which read from it. Or use a separate context system that is not metadata-based, like AsyncLocalStorage:
import { AsyncLocalStorage } from 'node:async_hooks';
export const requestContext = new AsyncLocalStorage<{ userId: string; requestId: string }>();
// In the auth interceptor:
requestContext.enterWith({ userId, requestId: metadata.get('x-request-id')[0] as string });
// In the handler:
const { userId } = requestContext.getStore()!;
This eliminates the metadata ordering dependency and gives you a single source of truth for per-request context that works across the entire async chain.
Client-side interceptors
The same pattern works for client calls. Client interceptors wrap every outgoing request:
import * as grpc from '@grpc/grpc-js';
function clientLoggingInterceptor(
options: any,
nextCall: (options: any) => any
) {
return (method: any, request: any, metadata: any) => {
const start = Date.now();
const call = nextCall(options)(method, request, metadata);
call.on('finish', () => {
logger.info({ method: method.path, duration: Date.now() - start });
});
return call;
};
}
const client = new MyServiceClient(
'server:50051',
grpc.credentials.createInsecure(),
{
interceptors: [clientLoggingInterceptor],
}
);
Client-side interceptors are invaluable for adding tracing headers, logging outgoing call latency, and applying client-side retry logic without wrapping every client.getOrder() call in a retry function.
Testing interceptors in isolation
Because each interceptor is a pure function that takes (call, metadata, next) and returns nothing, you can test it without a running gRPC server:
import { EventEmitter } from 'node:events';
import { Metadata } from '@grpc/grpc-js';
test('auth interceptor rejects missing tokens', () => {
const call = new EventEmitter() as any;
call.sendError = vi.fn();
call.getPath = () => '/orders.OrderService/GetOrder';
const metadata = new Metadata();
const next = vi.fn();
authInterceptor(() => null)(call, metadata, next);
expect(call.sendError).toHaveBeenCalledWith(
expect.objectContaining({ code: grpc.status.UNAUTHENTICATED })
);
expect(next).not.toHaveBeenCalled();
});
No gRPC server, no protobuf loading, no new Server(). The interceptor either calls next() or calls sendError. Assert on those two things. This makes interceptor testing faster and more reliable than testing handlers, which means you can build a test suite that validates auth failures, error mapping, metrics emission, and logging output in under 10ms per test.
When not to use interceptors
Interceptors add indirection. If your service has three RPCs, the four-interceptor chain above is overengineering. Write the boilerplate and move on. The indirection cost (reading the interceptor files, understanding the chain order, debugging the event wrapping) is higher than the copy-paste cost.
If your service has 15+ RPCs, interceptors pay for themselves. The 200 lines of interceptor code replace 900+ lines of handler boilerplate (60 lines per handler x 15 handlers). Every time you add a new RPC, you write the business logic and nothing else. Every time you change the auth scheme, you edit one file.
The takeaway
gRPC interceptors are the middleware pattern for gRPC, and they solve the same problem Express middleware solves: separating cross-cutting concerns from business logic. The pattern is four interceptors (auth, logging, error mapping, metrics), a carefully ordered chain, and a safeNext wrapper that catches async errors. Every interceptor is testable in isolation, every interceptor works for all RPCs, and every interceptor can be removed or replaced without touching handlers.
The three traps (order inversion, async swallowing, metadata ordering) are well-documented once you know to look for them. Use AsyncLocalStorage for context, wrap next() with a promise-aware boundary, and document the interceptor stack order in your server setup file.
Your handlers should be business logic and nothing else. That is the whole point.
A note from Yojji
Building gRPC services that are observable, secure, and maintainable under real production traffic is the kind of engineering discipline that separates a prototype from a platform. The patterns in this post (composable interceptors, structured error codes, testable middleware boundaries) are the same patterns that Yojji has shipped across dozens of Node.js microservice projects since 2016.
Yojji is an international custom software development company with offices in Europe, the US, and the UK. Their teams specialize in the JavaScript stack (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and microservices architectures, and they run dedicated senior outstaffed teams alongside full-cycle product engagements covering discovery, design, development, QA, and DevOps.
If you would rather hire a team that already knows how to wire interceptor chains and test async error boundaries than learn it through a production incident, Yojji is worth a conversation.