What a gRPC deadline is and why it is a request-wide budget
A gRPC deadline is an absolute point in time after which the caller no longer wants the result. Each process holds it as a local timestamp, but it does not travel that way. On the wire it is sent as the relative grpc-timeout header, for example "remaining: 800 milliseconds." The receiver adds that duration to its own clock to rebuild an absolute deadline. Because only a duration crosses the network, clock skew between machines largely cancels out. The real error is transit time, which neither side subtracts, so the server's deadline lands slightly later than the client's.
Every hop consumes part of the same budget. Serialization, network latency, connection setup, and time spent waiting in a server's queue before the handler runs all count against it, and none of it appears in handler-level timing. That is why the deadline is a request-wide budget and not a per-call setting.
Fixed per-hop timeouts ignore what has already been spent.
Illustrative estimate (assumed numbers, not a benchmark): the gateway allows 1,000 ms, and orders calls inventory with a fixed 2,000 ms timeout. Assume orders reaches inventory 900 ms in.
- Useful window = caller budget − elapsed = 1,000 − 900 = 100 ms
- Potentially wasted work = per-hop timeout − useful window = 2,000 − 100 = 1,900 ms
That 1,900 ms is time inventory may keep working after nobody is waiting for the answer.
Services with fan-out or multi-hop chains benefit most, because waste multiplies across branches. With no deadline at all, a call can by default wait indefinitely, holding sockets, memory, and handler capacity.
Setting and reading deadlines with grpc-js
On the client, deadline is a call option passed alongside metadata. grpc-js accepts either a Date or a number of milliseconds since the epoch, so { deadline: Date.now() + 500 } and { deadline: new Date(Date.now() + 500) } are equivalent. Both are absolute points in time, not durations. Compute the value immediately before the call. If you compute it once at startup and reuse it, every later request inherits a deadline that has already expired. Omitting the option gives you the unbounded call described above. The two calls look almost identical in code, which is why a missing deadline is easy to miss in review.
On the wire, the client sends a relative timeout rather than a timestamp. The server converts it back to an absolute deadline using its own clock, so clock skew between hosts does not distort the budget, although network transit time does consume part of it.
On the server, call.getDeadline() returns that reconstructed value. Its type is Date | number, so normalize it before doing arithmetic: call getTime() on a Date and use a number as-is. If the client sent no deadline, grpc-js reports Infinity. Your code must handle that value explicitly rather than pass it to setTimeout or a database driver, many of which reject or mishandle it.
A small helper keeps this consistent across handlers. remainingMs(call) normalizes the deadline, returns Infinity when none was set, and otherwise returns Math.max(0, deadline - Date.now()). The clamp to zero matters: a negative budget means the work is already late, and callers should fail fast instead of starting it.
import * as grpc from '@grpc/grpc-js';
// Reusable helper: remaining budget in ms, Infinity if the client set no deadline.
export function remainingMs(call: { getDeadline(): Date | number }): number {
const d = call.getDeadline();
const deadline = d instanceof Date ? d.getTime() : d; // normalize Date | number
if (!Number.isFinite(deadline)) return Infinity; // no deadline was sent
return Math.max(0, deadline - Date.now()); // clamp: 0 means already late
}
// --- Client: compute the absolute deadline right before each call ---
declare const client: any; // generated stub, e.g. new UserServiceClient(...)
const md = new grpc.Metadata();
client.getUser({ id: 'user-123' }, md, { deadline: Date.now() + 500 }, cb); // number
client.getUser({ id: 'user-123' }, md, { deadline: new Date(Date.now() + 500) }, cb); // Date, same meaning
client.getUser({ id: 'user-123' }, md, cb); // no deadline: unbounded call, easy to miss in review
function cb(err: grpc.ServiceError | null, res?: unknown) {
if (err?.code === grpc.status.DEADLINE_EXCEEDED) console.warn('timed out');
}
// --- Server: read the deadline and refuse work that cannot finish in time ---
export function getUser(
call: grpc.ServerUnaryCall<{ id: string }, unknown>,
callback: grpc.sendUnaryData<unknown>,
) {
const budget = remainingMs(call);
if (budget === 0) {
return callback({ code: grpc.status.DEADLINE_EXCEEDED, details: 'deadline already passed' });
}
// Never hand Infinity to timers or drivers; substitute an explicit server-side cap.
const timeoutMs = Number.isFinite(budget) ? budget : 5_000;
console.log(`looking up ${call.request.id} with ${timeoutMs} ms budget`);
callback(null, { id: call.request.id });
}
A grpc-js client passing deadline as a number or a Date versus omitting it, and a server handler that uses a remainingMs helper to normalize getDeadline(), handle the Infinity case, and fail fast when the budget is already spent.
How to propagate deadlines to downstream calls
With a remaining-budget helper in place, propagation is one rule: the downstream deadline is the earlier of the incoming deadline and a local cap, minus a safety margin. The cap keeps a slow dependency from consuming a generous upstream budget. The margin reserves time to deserialize the response, run local logic, and reply before the caller gives up.
Margin size is a trade-off. Too small, and the downstream call can succeed just as your own deadline expires, so the work is wasted. Too large, and you shorten every hop's budget and cause DEADLINE_EXCEEDED errors on requests that would have finished. As an illustrative estimate, a handler that spends a few milliseconds serializing might reserve 10–20 ms. Measure your own handlers before you choose a value.
Compute the deadline immediately before issuing the call, not when the handler starts. A garbage-collection pause or synchronous work such as JSON parsing or crypto blocks the event loop, so a budget computed earlier is already stale by the time the request goes out.
grpc-js also has a built-in option. You pass the incoming call as the parent call option and choose what it inherits with propagate_flags, using the grpc.propagate constants for deadline and cancellation. This is concise, but the inheritance is implicit. Write tests that confirm the deadline and cancellation actually reach downstream services in your setup.
| Approach | Readability | Testability | Cross-language consistency | Hidden behavior |
|---|---|---|---|---|
| Explicit min(deadline, cap) − margin | Clear at call site | Easy to unit-test | Portable pattern | None |
parent + propagation flags |
Concise | Needs integration tests | grpc-js-specific API | Implicit inheritance |
| Fixed per-hop timeouts | Simple | Easy | Consistent | Ignores caller's budget |
import * as grpc from '@grpc/grpc-js';
// Illustrative values only: measure your own handlers before choosing.
const LOCAL_CAP_MS = 500; // cap so a slow dependency can't use a generous upstream budget
const SAFETY_MARGIN_MS = 15; // reserve for deserializing, local logic, and replying
// Stand-in for a generated client stub.
declare const inventory: grpc.Client & {
getStock(req: object, md: grpc.Metadata, opts: grpc.CallOptions,
cb: (err: grpc.ServiceError | null, res?: unknown) => void): grpc.ClientUnaryCall;
};
function downstreamDeadline(call: grpc.ServerUnaryCall<any, any>): Date {
const incoming = call.getDeadline(); // a Date, or Infinity if the caller set no deadline
const incomingMs = incoming instanceof Date ? incoming.getTime() : incoming;
const now = Date.now(); // read the clock at send time, not at handler start
return new Date(Math.min(incomingMs, now + LOCAL_CAP_MS) - SAFETY_MARGIN_MS);
}
export function getOrder(call: grpc.ServerUnaryCall<any, any>, cb: grpc.sendUnaryData<any>) {
const parsed = JSON.parse(call.request.payloadJson); // sync work: blocks the event loop
// Compute after the blocking work, immediately before sending.
const deadline = downstreamDeadline(call);
if (deadline.getTime() <= Date.now()) {
return cb({ code: grpc.status.DEADLINE_EXCEEDED, details: 'no budget left' });
}
const downstream = inventory.getStock({ sku: parsed.sku }, new grpc.Metadata(), { deadline },
(err, res) => (err ? cb(err) : cb(null, { stock: res })));
// Explicit cancellation propagation: stop downstream work if our caller goes away.
call.on('cancelled', () => downstream.cancel());
// Built-in alternative (implicit; verify with integration tests in your setup):
// { parent: call, propagate_flags: grpc.propagate.DEADLINE | grpc.propagate.CANCELLATION }
}
A grpc-js unary handler that computes the downstream deadline as min(incoming deadline, local cap) minus a safety margin right before the call, fails fast when no budget remains, forwards cancellation explicitly, and notes the parent/propagate_flags alternative.
Handling cancellation on the server and bridging it to AbortSignal
On the server, grpc-js exposes cancellation on every call object: call.cancelled is a boolean you can poll, and the call emits a 'cancelled' event when the client cancels, the connection drops, or the deadline expires. Nothing stops your handler automatically. The client has given up, but your code keeps consuming CPU, connections and downstream capacity until it checks one of these.
For long loops, check call.cancelled between iterations or batches and return early. For everything else, bridge the event to an AbortController. Create one per request, call controller.abort() inside the 'cancelled' listener, and pass controller.signal to anything that accepts it, such as fetch and the many database drivers that take a signal. One listener then reaches every library that understands AbortSignal.
Outgoing gRPC calls need the reverse bridge. Keep a reference to each client call and invoke call.cancel() from the same abort handler, or from signal.addEventListener('abort', ...). Child calls then stop when the parent stops instead of finishing their full budget for a response nobody will read.
Cancellation is cooperative. A synchronous CPU-bound loop never yields, so the event cannot fire until it finishes. Libraries without signal support ignore the abort. A query the database has already received may run to completion. A committed write stays committed, because nothing is rolled back.
As a result, a client that sees CANCELLED or DEADLINE_EXCEEDED cannot tell whether a non-idempotent operation succeeded. Before retrying, it needs an idempotency key the server deduplicates on, or a status lookup endpoint to check the outcome.
import * as grpc from '@grpc/grpc-js';
declare const inventoryClient: any; // generated client for a downstream service
declare function computeBatch(i: number, rates: unknown, stock: unknown): unknown;
export async function buildReport(call: grpc.ServerUnaryCall<any, any>, callback: grpc.sendUnaryData<any>) {
// One controller per request, aborted on client cancel, connection drop or deadline expiry.
const controller = new AbortController();
call.on('cancelled', () => controller.abort());
const { signal } = controller;
try {
// Signal-aware libraries (fetch, many DB drivers) stop when the call is cancelled.
const rates = await (await fetch('', { signal })).json();
// Reverse bridge: cancel the outgoing gRPC call when the parent is cancelled.
const stock = await new Promise((resolve, reject) => {
const child = inventoryClient.getStock({ sku: call.request.sku }, { deadline: call.getDeadline() },
(err: grpc.ServiceError | null, reply: unknown) => (err ? reject(err) : resolve(reply)));
if (signal.aborted) child.cancel();
else signal.addEventListener('abort', () => child.cancel(), { once: true });
});
const rows: unknown[] = [];
for (let i = 0; i < call.request.batches; i++) {
if (call.cancelled) return; // client is gone; stop spending CPU on an unread response
rows.push(computeBatch(i, rates, stock)); // synchronous: cannot be interrupted mid-batch
await new Promise((r) => setImmediate(r)); // yield so the 'cancelled' event can be delivered
}
// Anything already committed above stays committed; cancellation rolls nothing back.
callback(null, { rows });
} catch (err) {
if (signal.aborted) return; // call already ended as CANCELLED or DEADLINE_EXCEEDED
callback(err as grpc.ServiceError);
}
}
A grpc-js unary handler that bridges the 'cancelled' event to an AbortController for fetch, cancels a downstream gRPC call when the parent is cancelled, and polls call.cancelled between batches of a CPU-bound loop, yielding between batches so the event can be delivered.
DEADLINE_EXCEEDED vs CANCELLED, retries, and streaming calls
DEADLINE_EXCEEDED usually comes from the client's own timer: the budget ran out, and the client stopped waiting. The server may still be working, or may already have committed. CANCELLED means someone abandoned the call. On a client, that is normally your own code calling cancel(). On a server, it means the caller disconnected or gave up. Neither status says anything about whether the operation took effect.
That ambiguity is one reason not to retry blindly. The other is the budget. A retry issued after the parent deadline has passed does work nobody will read. Retries or hedged requests inside a hop share the same inherited deadline, so each attempt gets less time. During an incident they also multiply load on the dependency that is already struggling. Decide the attempt count and per-attempt timeout together with deadline propagation, and skip any retry the remaining budget cannot cover.
Streaming calls
A deadline covers the whole stream, not each message. A long-lived subscription with a 5-second deadline ends after 5 seconds, however active it is.
| Item | Produced by / mechanism | Client response |
|---|---|---|
DEADLINE_EXCEEDED |
Usually the client's timer, sometimes the server | Retry only if idempotent and budget remains |
CANCELLED |
Explicit cancel, or a disconnect seen by the server | Don't retry by default. The caller chose to stop. |
| Infinite deadline + explicit cancel | Stream lives until either side cancels | Needs keepalives and disciplined cancellation |
| Finite deadline + reconnect + idle policy | Stream expires, client resumes from a cursor | Bounded resources, predictable rebalancing |
Server-streaming handlers must check call.cancelled or listen for the 'cancelled' event, then stop writing and release resources. Otherwise they keep producing data for a client that is gone.
const grpc = require('@grpc/grpc-js');
// Server-streaming handler. The deadline covers the whole stream, not each
// message, so the handler must stop producing once the call is abandoned.
function watchPrices(call) {
let seq = 0;
const timer = setInterval(() => {
if (call.cancelled) return stop(); // never write to an abandoned call
call.write({ seq: ++seq, symbol: 'ACME', price: 101.25 });
}, 1000);
function stop() {
clearInterval(timer); // release the producer: timers, DB cursors, subscriptions
}
call.on('cancelled', stop); // client cancelled or disconnected
}
// Client side: retry an idempotent unary read only while the inherited
// (parent) deadline can still cover a meaningful attempt.
function getQuote(client, req, parentDeadline, attempt = 1) {
return new Promise((resolve, reject) => {
const remainingMs = parentDeadline.getTime() - Date.now();
if (remainingMs < 200) {
return reject(new Error('remaining budget too small; not retrying'));
}
// Each attempt is capped and can never outlive the parent deadline.
const deadline = new Date(Date.now() + Math.min(remainingMs, 1000));
client.getQuote(req, { deadline }, (err, res) => {
if (!err) return resolve(res);
const retryable = err.code === grpc.status.DEADLINE_EXCEEDED ||
err.code === grpc.status.UNAVAILABLE;
// CANCELLED means the caller chose to stop: surface it, never retry.
if (!retryable || attempt >= 2) return reject(err);
resolve(getQuote(client, req, parentDeadline, attempt + 1));
});
});
}
A grpc-js server-streaming handler that stops writing and frees resources when the call is cancelled, plus a client retry that never retries CANCELLED and skips any attempt the parent deadline cannot cover.
End-to-end example: gateway → orders → inventory
The chain below uses an 800 ms budget set at the gateway. Each hop reads the deadline it received and subtracts a small safety margin before calling the next service. All timings are an illustrative estimate, not measurements.
client ──► gateway deadline set: now + 800 ms
│ ~15 ms network
▼
orders remaining ≈ 785 ms
│ ~120 ms DB read, −20 ms margin
▼
inventory remaining ≈ 645 ms
cancel path: client disconnects ──► gateway call cancelled
──► orders 'cancelled' ──► inventory 'cancelled' ──► stop query
The budget shrinks because elapsed time is subtracted at each hop. Inventory never gets a fresh 800 ms. When the client disconnects, the gateway's call is cancelled. That cancellation flows down through the parent/propagation options, or through the AbortSignal bridge from section 4, so inventory abandons its query instead of finishing work nobody will read.
Logging for attribution
On entry, each handler should log the service name, the method, and remaining_ms (call.getDeadline() minus now). On the 'cancelled' event it should log a cause. The server-side event does not say whether the deadline fired or the peer went away, so infer the cause yourself: if the current time is past the deadline, record deadline; otherwise record client_cancel. Without this field, every timeout looks like the gateway's fault.
Forcing DEADLINE_EXCEEDED at a chosen hop
In tests, give each service an injectable delay. Set inventory's delay to exceed its remaining budget, for example 700 ms against roughly 645 ms. Then assert three things:
- The gateway receives
DEADLINE_EXCEEDED. - Inventory logged
cause=deadline. - Orders did not start a second downstream call.
Repeat the test with the delay on orders to confirm that the attribution moves with it.
When to avoid inheriting the caller's deadline, and the impact on cost and engineering time
Where inheritance is the wrong fit
Propagation assumes the downstream work only matters while the caller is waiting. That is false in two cases:
- Fire-and-forget work such as audit writes or cache warming. If it inherits a 300 ms request budget, it gets cancelled as soon as the response is sent. Give it its own deadline and detach it from the parent's cancellation.
- Slow but valuable operations such as report generation or bulk reconciliation. The tightest caller's budget will cut these off. Return a job ID, run the work from a queue with its own deadline, and let clients poll for status.
| Approach | Use when | Trade-off |
|---|---|---|
| Inherit caller deadline | Interactive request path | Short callers cut off long work |
| Per-operation-class policy | Mixed latency classes | Policy table to maintain |
| Async job + status polling | Work outlives the request | Extra state and endpoints |
grpc-js parent call option |
Direct gRPC-to-gRPC hops | No effect across queues or HTTP |
Cost versus payoff
The upfront work is a deadline helper, an AbortSignal bridge, tests for both status codes, and a cause log field.
Illustrative estimate (assumed numbers, not a benchmark). Formula: wasted server-seconds per day = timed-out requests × downstream work per request. Assume 2,000 timeouts/day and 1.5 s of orphaned downstream work each. That is 3,000 server-seconds/day of work whose result nobody receives. The larger payoffs are harder to quantify: faster timeout attribution during incidents and fewer retry storms.
Adoption checklist
- Classify each RPC as interactive, background, or long-running.
- Propagate deadlines only for interactive calls.
- Log
causeon every cancellation. - Test injected delays at every hop.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to grpc deadlines node.js, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Consumers learn about a breaking change at review time rather than from production errors.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.