NestJS Terminus 12 removed HealthCheckError. Fix your probes
By Nihar Ranjan Das · Sun Oct 11 2026 · 10 min read · 0 views
View as a Web StorySoftware#node.js#observability#kubernetes#nestjs#terminus#health checks

Terminus 12 deletes the HealthIndicator base class, the getStatus() helper and every health error class, including HealthCheckError. A v11 indicator that threw HealthCheckError answered with HTTP 503. If you replace that throw with a plain throw new Error(...) while upgrading, the same failure answers with HTTP 500. The correct v12 pattern is to return down() from HealthIndicatorService, which keeps the 503.
This guide shows the change in running code. We measured the status codes on both versions, tested the new helpers, and wrote a safe migration path. The bottom line: grep for HealthCheckError before you bump the package, because nest upgrade will not touch Terminus for you.
What did Terminus 12 remove?
Terminus is the official NestJS module for health checks. It exposes an endpoint such as /health that load balancers, container orchestrators and uptime monitors call to learn whether your service can take traffic. Version 12.0.0 shipped alongside NestJS 12.
The Terminus 12.0.0 release notes list the removals. The deprecated HealthIndicator base class and its getStatus() method are gone. The deprecated error classes are no longer exported, including HealthCheckError, TimeoutError and MongoConnectionError. The package is also ESM only and requires Node ^20.19.0 || ^22.12.0 || >=24.0.0.
The NestJS 12 migration guide describes the replacement. You inject HealthIndicatorService, call check('key'), and return up() or down(). The guide also deprecates the timeout option on the database, microservice and gRPC indicators. You now call .withTimeout(ms) on an attempt.
The notes add one behavior change in a single line: a thrown error is treated as a 500 failure, and only a returned down result gives a 503. That line is the reason for this guide.
What HTTP status do my probes see now?
Your probes see 503 if you return down() and 500 if you throw. We tested both versions with the same dependency failure written four ways.
Test setup: two small Nest apps on Node 24.15.0, one with @nestjs/terminus 11.1.1 on NestJS 11.2.7, one with @nestjs/terminus 12.1.0 on NestJS 12. Each exposed routes that failed in a different way. We called each route with curl and recorded the status code.
| Failure written as | Terminus 11.1.1 | Terminus 12.1.0 |
|---|---|---|
throw new HealthCheckError(...) |
503 | Not exported |
throw new Error(...) |
500 | 500 |
return indicator.down(...) |
Not available | 503 |
indicator.attempt(...) that throws |
Not available | 503 |

The v11 response body for the HealthCheckError case looked like this, with status 503:
Advertisement
{"status":"error","info":{},"error":{"db":{"status":"down","m":"x"}},
"details":{"db":{"status":"down","m":"x"}}}
The v12 down() response had the same shape and the same 503. The v12 thrown error returned {"statusCode":500,"message":"Internal server error"}, which hides which indicator failed.
Here is the trap. The second row did not change. A plain throw new Error was a 500 on v11 and is a 500 on v12. But the first row used to be the way to get a 503. When HealthCheckError disappears, a quick fix that swaps in a plain Error compiles, runs, and quietly turns your 503 into a 500.
Why does a 500 instead of a 503 matter?
It matters because tools downstream read the status code to decide what happened. A 503 says "dependency unavailable, try again". A 500 says "the service itself broke". Those two readings feed different alerts.
Consider what each tool does with the difference:
- Container probes. An HTTP probe in Kubernetes treats a 2xx or 3xx response as healthy and anything else as a failure. A 500 and a 503 fail the probe the same way, so restarts and traffic removal behave the same.
- Alerting rules. Many teams page on a rise in 500 responses and only warn on 503. A degraded dependency now pages the on-call engineer at night.
- Error budgets. Service-level objectives that count 5xx as errors count both, but dashboards that split them show a false spike of application bugs.
- Response body. The thrown error hides the indicator name. An engineer reading the alert sees "Internal server error" and no hint about the database.
So the damage is mostly about signal. Your pods will still restart. Your pager and your dashboards will tell the wrong story. That is the kind of regression nobody tests for, because the health route still answers.
How do you migrate a custom indicator from v11 to v12?
Replace inheritance with an injected service and return the result. Here is a v11 indicator that checks a dog count, adapted from the pattern in the NestJS guide:
// v11
@Injectable()
export class DogHealthIndicator extends HealthIndicator {
async isHealthy(key: string): Promise<HealthIndicatorResult> {
const bad = await this.countBadDogs();
const result = this.getStatus(key, bad === 0, { badboys: bad });
if (bad === 0) return result;
throw new HealthCheckError('Dog check failed', result);
}
}
And the v12 version, which returns instead of throwing:
// v12
@Injectable()
export class DogHealthIndicator {
constructor(private readonly healthIndicatorService: HealthIndicatorService) {}
async isHealthy(key: string) {
const indicator = this.healthIndicatorService.check(key);
const bad = await this.countBadDogs();
return bad === 0 ? indicator.up() : indicator.down({ badboys: bad });
}
}
Four changes cover the move. The class no longer extends anything. The service arrives through the constructor. check(key) replaces getStatus. And down() replaces the thrown error.
We ran this exact shape against Terminus 12.1.0. The indicator that returned down({ m: 'x' }) answered 503 with "status":"down" and the extra data in the body. The data key status is reserved, and the TypeScript types reject it in your extra data.
Do the migration in this order:
- Search your repo for
HealthCheckError,HealthIndicatorandgetStatus. - Convert one indicator and call its route with
curl -i. Confirm the status line. - Convert the rest, then run your health route in staging for one probe interval.
- Only then bump the package version in production.
What does nest upgrade do about Terminus?
It does nothing about Terminus. We ran nest upgrade --dry-run on a version 11 project that listed @nestjs/terminus in its dependencies. The report updated @nestjs/common, core, testing, platform-express, cli, schematics and config. It then printed a warning that @nestjs/terminus was left untouched because its v12-compatible release was not known to the schematic.
So your health module stays on 11 while everything else moves to 12. That mix may trip peer-dependency checks at install time, or it may run with the old behavior. The report suggests running npx npm-check-updates '/^@nestjs\//i' -u, which would bump Terminus along with the rest.
Treat the warning as a to-do item. The package bump and the code migration must land in the same pull request.
What are the new Terminus 12 helpers good for?
The new helpers solve problems that v11 left to your own code. We tested four of them on Terminus 12.1.0.
| Helper | What it does | Measured result |
|---|---|---|
attempt(fn) that throws |
Marks the indicator down with the error message | 503 in 10 ms |
.withTimeout(300) on a 2,000 ms dependency |
Cuts the wait and marks it down | 503 in 303 ms |
degraded({ note }) |
Reports impaired but serving | 200 with "status":"degraded" |
.cacheFor(5000) |
Reuses the last result | Second call returned cachedResponse: true |

Each helper deserves a short explanation.
attempt(fn) wraps your check in a try and catch. If the function throws, the indicator reports down with the error message and a response time. You no longer write the try and catch yourself, and you cannot forget to convert the error. This is the safest default for a database ping:
return this.healthIndicatorService
.check('db')
.attempt(async () => this.db.ping());
.withTimeout(ms) is the supported way to bound a slow dependency. In our run the dependency took 2,000 ms, the timeout was 300 ms, and the route answered in 303 ms with the message timeout of 300ms exceeded. Without a timeout, your probe waits as long as the dependency does, and the orchestrator may time out first and report a confusing failure.
degraded() adds a state v11 did not have. The overall status becomes "degraded", and the HTTP status stays 200. This fits a dependency such as a read replica that lags. The service still serves, and the response body tells monitoring something is off. Use it carefully, because a 200 keeps traffic flowing.
.cacheFor(ms) stops a hot probe from hammering a dependency. Our first call ran the function once. The second call, inside the window, returned the same data with cachedResponse: true and did not run the function again. If your load balancer probes every few seconds from many nodes, a cache of a few seconds cuts the load on the database.
How should you choose between down, degraded and a thrown error?
Use down() when the service cannot do its job, degraded() when it can but something is impaired, and never throw for a health condition. A thrown error means a bug in the check. A returned down means a real dependency failure.
The same rule works as a table you can paste into a code review:
| Situation | Return | Status | Who gets paged |
|---|---|---|---|
| Database refuses connections | down() |
503 | Dependency owner |
| Replica lag above your limit | degraded() |
200 | Nobody, a dashboard flag |
| Your check code has a typo | Throw | 500 | Your own team |
The last row is the one HealthCheckError used to blur. Keeping a thrown error as a 500 is now useful, because it separates a broken check from a broken dependency.
What else changed in Terminus 12 that can surprise you?
Four smaller changes in the release notes can change behavior without a compile error. Each one is easy to miss in a code review.
- Response times appear in results.
HealthCheckResultnow carriesresponseTime. Ourattempt()runs showed it inside each indicator, for example"responseTime":302on the timeout case. Dashboards that parse the body strictly may need a schema update. - The Mongoose indicator pings the database. It used to check
readyState. A connection that looks open but cannot answer now reports down. - RabbitMQ checks no longer assert a queue on each probe. The old behavior created load and side effects on the broker. The new one is quieter.
- The shutdown flag is set earlier. The
shutting_downstate is now set before the graceful-shutdown timeout, so probes see it sooner during a deploy.
None of these needs a code change. All of them can shift what your monitoring sees on the first deploy, so read the release notes once with your alert rules open beside them.
Should you upgrade Terminus now or wait?
Upgrade when you have time to migrate every custom indicator in one pull request, and not before. Terminus 11 still works with NestJS 11. The InfoQ coverage of the NestJS 12 roadmap shows the framework moving to ESM, so the migration is a matter of when.
Wait if you depend on a custom indicator package that has not published a version 12 release. Upgrade now if your health module is small, because the new helpers remove code. Whichever you choose, check three things before the deploy:
- Call every health route with
curl -iand read the status line. - Compare the alerts that would fire for a 503 and a 500 in your monitoring.
- Confirm Node is 20.19, 22.12 or 24 and later, because the package is ESM only.
If your service runs on AWS Lambda with a CommonJS build, read NestJS 12 on AWS Lambda: the ESM crash and how to fix it first. The same require(esm) rule applies to this package.
Responsible operations teams should document the intended behavior of every monitoring endpoint, including the specific conditions that produce a degraded, unavailable or unexpected response, because ambiguous health semantics frequently lead to misleading alerts, unnecessary escalations and delayed incident recovery. Writing those definitions down before the upgrade also gives reviewers a concrete reference when they evaluate whether the migrated indicators preserve the established operational expectations.
How did we test these fixes?
The methodology is simple and repeatable. Versions tested: @nestjs/terminus 11.1.1 on @nestjs/core 11.2.7, @nestjs/terminus 12.1.0 on NestJS 12, Node.js 24.15.0, and @nestjs/cli 12.0.8 for the dry run. Status codes and timings come from curl against a local server, one run per route.
Advertisement
FAQ
What replaces HealthCheckError in NestJS Terminus 12?
Nothing is thrown any more. Inject HealthIndicatorService, call check with your key, and return up or down from the indicator. A returned down result keeps the HTTP 503 response. A thrown error is treated as an unexpected failure and returns 500, so do not swap HealthCheckError for a plain Error.
Why does my NestJS health check return 500 instead of 503?
Terminus 12 treats any thrown error as an unexpected failure with status 500. Only a returned down result gives 503. In our tests a v11 indicator that threw HealthCheckError returned 503, and the same failure rewritten as a plain throw returned 500 on both versions.
Does nest upgrade migrate @nestjs/terminus to version 12?
No. In our dry run the report updated common, core, testing, platform-express, cli, schematics and config. It then warned that @nestjs/terminus was left untouched because its v12-compatible release was not known to the schematic. Bump the package and rewrite custom indicators yourself in the same pull request.
How do I add a timeout to a Terminus 12 health check?
Call withTimeout on an attempt. In our run a dependency that took 2,000 ms was cut off at 300 ms, and the route answered 503 in 303 ms with the message timeout of 300ms exceeded. The old timeout option on the database, microservice and gRPC indicators is deprecated.
Comments
Loading…
Sign in to join the conversation.
Related posts

NestJS 12 on AWS Lambda: the ESM crash and how to fix it
NestJS 12 ships ESM-only, and AWS Lambda turns off require(esm). We reproduced the ERR_REQUIRE_ESM crash on five Node versions and tested three fixes.
Sun Oct 11 2026 · 10 min read · 0 views

NestJS 12 and Jest: why your tests fail and how to fix them
Jest cannot load NestJS 12's ESM-only packages on most Node versions. We ran one spec on four Node versions and tested every fix, including Vitest.
Sun Oct 11 2026 · 10 min read · 0 views

NestJS 12 upgrade: the traps nest upgrade does not fix
nest upgrade fails with Invalid command or Schematic not found on a v11 project. We tested the fix, read its report, and listed what you must still do by hand.
Sun Oct 11 2026 · 10 min read · 0 views