open atlas
↑ Back to track
NestJS, zero to senior NEST · 07 · 02

Health checks and cross-cutting interceptors

@nestjs/terminus aggregates health indicators behind /health. The senior split: liveness must stay dependency-free (it triggers restarts), readiness checks deps and gates the LB. Layer TimeoutInterceptor + a metrics interceptor on top.

NEST Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

A primary database had a forty-second blip — a failover, nothing dramatic. Forty seconds later your entire fleet was down, and it stayed down well after the database recovered. The postmortem was short and brutal: someone had wired db.pingCheck('database') into the livenessProbe. When the DB went away, every pod failed liveness, so Kubernetes did exactly what it is told to do for a failed liveness probe — it killed and restarted the pods. The fresh pods also couldn’t reach the DB, failed liveness again, and got killed again. A transient dependency hiccup became a self-inflicted, cluster-wide restart storm. The health endpoint was not the problem. Putting a dependency check behind the liveness probe was.

Terminus: one endpoint, many indicators

@nestjs/terminus is Nest’s health-check toolkit. You inject HealthCheckService and call check([...]) with an array of indicator functions; the check is healthy only if every indicator reports up. Terminus ships indicators for the common dependencies — TypeOrmHealthIndicator (runs a SELECT 1 via pingCheck), HttpHealthIndicator.pingCheck(name, url), MemoryHealthIndicator.checkHeap(key, bytes), plus Mongoose/Prisma/Redis/Disk variants — and you write custom ones with HealthIndicatorService. The @HealthCheck() decorator wires up the Swagger/response shape.

import { Controller, Get } from '@nestjs/common';
import {
  HealthCheckService, HealthCheck,
  TypeOrmHealthIndicator, MemoryHealthIndicator,
} from '@nestjs/terminus';

@Controller('health')
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private db: TypeOrmHealthIndicator,
    private memory: MemoryHealthIndicator,
  ) {}

  // LIVENESS: "is this process alive and un-wedged?" — NO downstream deps.
  @Get('live')
  @HealthCheck()
  liveness() {
    return this.health.check([
      () => this.memory.checkHeap('memory_heap', 512 * 1024 * 1024),
    ]);
  }

  // READINESS: "can I serve traffic right now?" — deps ARE allowed here.
  @Get('ready')
  @HealthCheck()
  readiness() {
    return this.health.check([
      () => this.db.pingCheck('database'),
    ]);
  }
}

A custom indicator follows the same contract — HealthIndicatorService.check(key) gives you an indicator you mark up() or down({ ...detail }):

import { Injectable } from '@nestjs/common';
import { HealthIndicatorService } from '@nestjs/terminus';

@Injectable()
export class QueueHealthIndicator {
  constructor(private readonly hi: HealthIndicatorService) {}

  async isHealthy(key: string) {
    const indicator = this.hi.check(key);
    const depth = await this.queue.depth();
    return depth < 10_000 ? indicator.up({ depth }) : indicator.down({ depth });
  }
}

Liveness vs readiness: the distinction that prevents outages

This is the senior point, and the hook is the whole reason it matters. Kubernetes runs two probes with completely different failure semantics, and conflating them is how a small dependency blip turns into a fleet-wide outage.

  • Liveness answers “is the process alive and un-wedged?” A failed liveness probe makes the kubelet restart the pod. Therefore liveness must never depend on a downstream service. If your DB, a cache, or a queue is briefly unreachable, that is not a reason to kill your process — restarting won’t fix an external outage, it only amplifies it into a crash loop. Keep liveness to “the event loop responds and the process isn’t deadlocked.”
  • Readiness answers “can I serve traffic right now?” A failed readiness probe pulls the pod out of the Service / load-balancer endpoints but leaves the process running. So readiness can and should check the critical dependencies it needs to actually serve a request. When the DB returns, readiness passes again and the pod is put back in rotation — no restart, no lost process, no storm.

The matching Kubernetes probes point at the two separate endpoints:

livenessProbe:
  httpGet: { path: /health/live, port: 3000 }
  periodSeconds: 10
  failureThreshold: 3
readinessProbe:
  httpGet: { path: /health/ready, port: 3000 }
  periodSeconds: 5
  failureThreshold: 2

Readiness as the graceful-shutdown lever

The same readiness endpoint is your drain control on shutdown. On SIGTERM, you want the load balancer to stop sending new traffic before you start tearing down — otherwise in-flight requests get cut. So the correct shutdown order is: flip readiness to down first, wait for the LB to notice and drain, then close the server and connections. Nest’s shutdown hooks give you the seam:

@Injectable()
export class ReadinessState {
  private ready = true;
  isReady() { return this.ready; }
  setDraining() { this.ready = false; } // readiness indicator reads this
}

// In the readiness check, gate on this state so SIGTERM flips it to 503 first.
async beforeApplicationShutdown() {
  this.readinessState.setDraining();      // fail readiness -> LB drains us
  await sleep(this.drainGraceMs);         // give k8s a few probe intervals
}

That single ordering — readiness down, drain, then close — is what makes a rolling deploy lose zero requests. (This beforeApplicationShutdown hook fires only if app.enableShutdownHooks() was called; app-level shutdown wiring lives in the graceful-shutdown lesson. Here the point is which signal flips readiness, and why it must precede teardown.)

Cross-cutting interceptors for resilience and observability

Once you have health probes wired correctly, there is still a whole class of per-request concerns — what happens when a handler takes forever? who measures every request’s latency? — that don’t belong in any single handler. When you find yourself adding the same try/catch or timer to a dozen routes, that’s the signal to reach for an interceptor instead. Interceptors wrap next.handle() (an RxJS Observable) on both sides, which makes them the right home for the cross-cutting layer that brackets every request: a timeout so a wedged downstream can’t pin a request open forever, metrics so you can see latency and status, and caching for hot GETs. The canonical TimeoutInterceptor races the handler against an RxJS timeout() and converts the resulting TimeoutError into a proper RequestTimeoutException (a 408):

import {
  Injectable, NestInterceptor, ExecutionContext,
  CallHandler, RequestTimeoutException,
} from '@nestjs/common';
import { Observable, throwError, TimeoutError } from 'rxjs';
import { catchError, timeout } from 'rxjs/operators';

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    return next.handle().pipe(
      timeout(5000),
      catchError((err) =>
        err instanceof TimeoutError
          ? throwError(() => new RequestTimeoutException())
          : throwError(() => err),
      ),
    );
  }
}

A metrics interceptor records duration and outcome into a prom-client histogram, tapping both the success and error paths:

@Injectable()
export class MetricsInterceptor implements NestInterceptor {
  constructor(private readonly httpDuration: Histogram) {}
  intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
    const route = ctx.switchToHttp().getRequest().route?.path ?? 'unknown';
    const stop = this.httpDuration.startTimer({ route });
    return next.handle().pipe(
      tap(() => stop({ status: 'ok' })),
      catchError((err) => { stop({ status: 'error' }); return throwError(() => err); }),
    );
  }
}

The subtlety seniors must hold: global interceptors compose, and the outbound path unwinds in reverse. Inbound they run global → controller → route; the response stream unwinds route → controller → global, like nested function returns. So order matters. If you want metrics to record the real served latency including a timeout, the metrics interceptor must wrap outside the timeout interceptor — otherwise it stops its timer before the timeout fires and under-reports tail latency.

Check / concernEndpoint or componentDepends on downstream?k8s / effect on failure
Liveness/health/live — memory, event-loop onlyNo — neverPod is RESTARTED (killed + recreated)
Readiness/health/ready — db/cache/queue pingCheckYes — critical depsPod DRAINED from LB, process kept alive
TimeoutTimeoutInterceptor (RxJS timeout)Wraps the handler408 RequestTimeoutException, frees the request
MetricsMetricsInterceptor -> prom-clientWraps the handler (outermost)Records duration + status, no request effect
Why this works

Why must metrics wrap outside the timeout, not inside? Inbound order is the order you register global interceptors; outbound unwinds in reverse. If TimeoutInterceptor is outermost, the timeout() fires and the metrics interceptor’s catchError never sees the slow request as slow — the timer was already stopped, or the stream errored before tap ran on the real duration. Put metrics outermost and it brackets the entire request including the timeout path, so your p99 reflects what clients actually experienced. Ordering is not cosmetic; it changes what your dashboards say.

Pick the best fit

You have a Terminus health endpoint that runs db.pingCheck('database'). Where do you wire it, given that a brief DB outage must NOT restart every pod in the fleet?

Quiz

A failed Kubernetes liveness probe versus a failed readiness probe — what does each cause?

Quiz

You add a metrics interceptor and a timeout interceptor globally, and want p99 to include requests that time out. How do you order them?

Recall before you leave
  1. 01
    Explain the liveness-vs-readiness distinction and why a dependency check (db.pingCheck) belongs in only one of them.
  2. 02
    How do Terminus and interceptors compose into a production health + resilience layer, and why does interceptor ordering matter?
Recap

@nestjs/terminus exposes health behind HealthCheckService.check([…]), aggregating indicators — TypeOrmHealthIndicator.pingCheck, HttpHealthIndicator.pingCheck, MemoryHealthIndicator.checkHeap, and custom ones via HealthIndicatorService.up()/down() — where the check is healthy only if every indicator is up. The senior distinction is liveness vs readiness, and it has direct Kubernetes consequences. Liveness (“is the process alive and un-wedged?”) must stay dependency-free, because a failed liveness probe RESTARTS the pod, and restarting can never fix an external outage — it only amplifies a dependency blip into a fleet-wide restart storm. Readiness (“can I serve traffic right now?”) checks the critical dependencies, because a failed readiness probe only DRAINS the pod from the load balancer while leaving the process alive to recover. So wire db.pingCheck into /health/ready, never /health/live. Readiness is also the graceful-shutdown lever: on SIGTERM flip it to down first so the LB drains the pod before teardown. On top, cross-cutting interceptors carry resilience and observability — a TimeoutInterceptor (RxJS timeout() -> RequestTimeoutException), a metrics interceptor into prom-client, and CacheInterceptor for GETs. Global interceptors compose and unwind in reverse, so order matters: metrics goes outermost so p99 includes the timeout path. Now when you see a slow p99 on the dashboard, you can trust the number reflects what clients actually experienced — because the metrics interceptor is on the outside of the timeout, not hidden inside it.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.