Run one Node.js health check at a time, once per minute, and alert Slack, email, and a generic webhook only when the observed state changes. The deciding constraint isn't the timer. It's preventing a slow metrics API response from overlapping the next poll and making an older result overwrite a newer one.
Short answer: use a completion-aware loop, a strict response validator, an up/down state machine, and independent notification attempts. Treat timeouts, rejected responses, and invalid payloads as failed checks. Then monitor the monitor from outside its own process.
This is small enough to keep in one TypeScript file. It is not a toy, though. The behavior around timeouts, restarts, and ambiguous notification delivery is the actual implementation.
What makes a simple Node.js uptime poll fail every 1 minute?
setInterval(check, 60_000) looks right until a check takes longer than 60 seconds. The next callback starts anyway. Now two requests can finish out of order, both can mutate the same status, and both can emit alerts. A mutex can contain that mess, but it adds machinery to a problem the scheduler created. I prefer a loop that schedules the next run only after the current run settles.
Good health input is boring. This example accepts exactly { "healthy": boolean }. A timeout, network rejection, non-success status, malformed JSON, or different shape becomes down. That strictness matters because accepting a half-understood payload can report green while the upstream contract has already drifted.
The next choice is noisier: does one failed sample mean an outage? There is no universal answer. One failure gives fast detection but reacts to brief network noise. Requiring two or three consecutive failures reduces noise and delays the alert by one or two polling periods. Pick the rule from the service objective and test it with recorded failure patterns; don't cargo-cult a retry count.
No overlap.
State transitions keep delivery bounded. A twenty-minute outage should usually create one down event and one later up event, not twenty copies of the same message in three destinations. The first observation establishes the baseline in this build, so a process that starts while the service is down stays quiet until the state changes. That is a deliberate limitation. If startup failure must page immediately, alert on the first down observation and persist a deduplication key.
Tiny timer. Real policy.
There is also a distinction between checking health and scraping arbitrary metrics. A health decision should have a narrow contract. If the source exposes counters or histograms, evaluate the query at one boundary and hand the poller a boolean result; don't scatter query language, label assumptions, and threshold math through notification code. Prometheus instrumentation guidance also warns that each unique label set creates a new time series. Keep dimensions such as service and environment bounded. Raw URLs, recipient addresses, and request IDs do not belong in metric labels.
The smallest working implementation
The code uses the built-in Node.js fetch API and AbortSignal.timeout. Four environment variables are enough: one read URL plus generic webhook, email-adapter, and Slack-compatible write URLs. The email URL is an internal adapter contract, not an SMTP implementation. That keeps provider SDKs and credentials out of the state machine.
const intervalMs = 60_000;
const requestTimeoutMs = 10_000;
type Status = "up" | "down";
type HealthBody = { healthy: boolean };
type AlertEvent = {
id: string;
type: "uptime.state_changed";
status: Status;
checkedAt: string;
};
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
}
const metricsUrl = required("METRICS_URL");
const genericWebhookUrl = required("GENERIC_WEBHOOK_URL");
const emailAdapterUrl = required("EMAIL_ADAPTER_URL");
const slackWebhookUrl = required("SLACK_WEBHOOK_URL");
async function postJson(url: string, body: unknown): Promise<void> {
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(requestTimeoutMs),
});
if (!response.ok) {
throw new Error(`Notification rejected: ${response.status}`);
}
}
async function readStatus(): Promise<Status> {
const response = await fetch(metricsUrl, {
headers: { accept: "application/json" },
signal: AbortSignal.timeout(requestTimeoutMs),
});
if (!response.ok) {
throw new Error(`Health read rejected: ${response.status}`);
}
const body: unknown = await response.json();
if (
typeof body !== "object" ||
body === null ||
typeof (body as Partial<HealthBody>).healthy !== "boolean"
) {
throw new Error("Invalid health payload");
}
return (body as HealthBody).healthy ? "up" : "down";
}
async function notify(event: AlertEvent): Promise<void> {
const text = `Service is ${event.status} at ${event.checkedAt}`;
const attempts = await Promise.allSettled([
postJson(genericWebhookUrl, event),
postJson(emailAdapterUrl, {
id: event.id,
subject: `Uptime ${event.status}`,
text,
}),
postJson(slackWebhookUrl, { text }),
]);
const failures = attempts.filter((attempt) => attempt.status === "rejected");
if (failures.length > 0) {
throw new AggregateError(failures, "Notification delivery was incomplete");
}
}
let previous: Status | undefined;
async function check(): Promise<void> {
const checkedAt = new Date().toISOString();
let current: Status;
try {
current = await readStatus();
} catch (error) {
current = "down";
console.error("Health check failed", error);
}
if (previous !== undefined && previous !== current) {
const event: AlertEvent = {
id: `uptime:${current}:${checkedAt}`,
type: "uptime.state_changed",
status: current,
checkedAt,
};
await notify(event);
}
previous = current;
}
async function loop(): Promise<void> {
const startedAt = Date.now();
try {
await check();
} catch (error) {
console.error("Alert delivery failed", error);
} finally {
const elapsedMs = Date.now() - startedAt;
setTimeout(loop, Math.max(0, intervalMs - elapsedMs));
}
}
void loop();
The loop aims for a one-minute cadence when checks complete within a minute. If a check takes longer, the next begins immediately after completion, never concurrently. That means it favors single-flight correctness over catching up with missed ticks. I benchmark scheduler lag and request duration separately because combining them hides the reason a check started late.
Promise.allSettled gives every destination one independent attempt. One rejected email delivery does not prevent the Slack or generic webhook calls. There is deliberately no automatic write retry here: if the receiver committed a request but its response was lost, the caller cannot know whether another attempt will duplicate the alert. A receiver that supports idempotency can use the stable logical event ID and make bounded retries safe. Without that contract, report the ambiguous result rather than pretending it is a clean failure.
Failure tests matter more than the happy path
The first successful call proves very little. I want a fake metrics server to return this sequence: healthy JSON, unhealthy JSON, malformed JSON, a response delayed past ten seconds, and healthy JSON again. Fake notification receivers should record bodies and independently reject selected requests. Inject the interval, clock, and fetch function in the test version so a suite does not wait real minutes.
The assertions are about behavior. The initial healthy sample sends nothing. The transition to unhealthy sends one event to each destination. Malformed and delayed responses remain down, so they send nothing new. Recovery sends one event to each destination. If email rejects its recovery request, the other two destinations are still attempted and the aggregate delivery error is observable.
The timing case is worth spelling out because it catches the bug that a happy-path test misses. Start check A at 12:00:00 and hold its response. At 12:01:00, assert that check B has not started. Resolve A as unhealthy at 12:01:05, let the loop record down, and verify that B begins immediately because the scheduled delay has already elapsed. Now resolve B as healthy. The expected output is one recovery transition only if A followed an earlier up baseline; there must never be two reads in flight, and an older response must never land after B and reverse its state. Run the same sequence with the process starting at A and expect no initial notification, because this build uses the first observation as its baseline. This one controlled timeline verifies scheduling, ordering, transition suppression, and startup policy without sleeping for a real minute. It also makes a future refactor earn its keep: if someone swaps the recursive timeout for setInterval, the concurrent-read assertion fails immediately.
I'm not sure a single failure threshold is right for your network. Your mileage may vary. The evidence needed is concrete: distribution of check duration, frequency of transient connection errors, and the maximum detection delay your service objective permits. Put those inputs in a test fixture, then compare one-failure and consecutive-failure policies instead of debating them from intuition.
Restart behavior deserves a separate case. Since previous lives in memory, a restart discards the baseline and the next observation sends nothing. This is suitable for a small internal monitor where startup silence is accepted. It is not suitable when state must survive deployments, when alert acknowledgement matters, or when audit history is required. Persist the previous state and its observation time in those environments, and define an expiry rule so stale state does not generate a misleading recovery event.
Don't skip security tests. Logs must omit complete webhook URLs, authorization values, email addresses, and response bodies that may contain internal detail. Configuration should arrive through the deployment environment or a secret store, while error records should contain a destination name and classification rather than its credential-bearing URL.
What I would change at scale
The first addition is self-observation: record check start time, duration, classified outcome, transition count, and delivery result. Use a small bounded set of labels. A log line can carry an event ID for correlation, but turning every ID into a metrics label is a cardinality trap.
The monitor also needs an observer outside its failure domain. A heartbeat sent to a separate receiver can reveal a stopped event loop or dead process. An independently placed availability check can reveal a host or network failure shared by both the poller and its target. Local logs cannot announce that their machine disappeared.
Watch the watcher.
At larger scale I would separate collection, state evaluation, and notification routing. Durable state prevents deployment gaps. A queue gives delivery attempts an explicit lifecycle. Silences, ownership, escalation, and audit history become data rather than branches in one file. This costs more configuration and more components, so I wouldn't start there for two stable internal endpoints. I hate config bloat, but I hate an accidental monitoring platform hidden inside an application repository more.
The catch is clear: this one-file design stops fitting when checks run from multiple regions, alert state must be durable, teams need acknowledgement and escalation, or hundreds of targets require coordinated scheduling.
| Constraint | Better boundary |
|---|---|
| Metrics already have independent collection and rule evaluation | Keep alert state in that existing metrics-alerting stack |
| A shared host or network can take out both target and poller | Place an availability check outside that failure domain |
| One team owns a few stable internal endpoints | Keep the small poller and its narrow contract |
The table is a boundary test, not a ranking. Each option moves ownership somewhere else, and each adds its own deployment, configuration, or dependency surface.
For any CLI used to configure or test the monitor, telemetry is a separate consent decision from service health collection. Respect the DO_NOT_TRACK convention. Fewer knobs is good DX; silently collecting developer usage is not.
Trade-offs to decide before deployment
Write down five policies before calling the process done: the health payload contract, timeout, number of failures required for down, startup behavior, and notification idempotency. Those decisions determine what operators will see during the awkward cases. Environment variables merely carry them.
No monitor can prove its own availability. This implementation is simple because it draws a hard boundary around one polling process and three delivery adapters, then admits what remains outside: durable history, cross-region checking, human escalation, and independent supervision. That boundary is the useful part of the build.
Top comments (0)