A site can become unavailable because of an application error, a network timeout, a DNS problem, or a failed TLS connection. A small uptime monitor can check authorized URLs on a schedule, retry temporary failures, and email you when a site changes state.
This tutorial builds a Node.js monitor using the built-in fetch API and Nodemailer. It also shows how to limit targets to an explicit allowlist, avoid redirect surprises, and deduplicate alerts so one outage does not fill your inbox.
Key takeaways
- Monitor only URLs you own or are explicitly authorized to check.
- Keep targets in trusted configuration and restrict their hostnames with an allowlist.
- Use request timeouts, a small retry budget, and a clear definition of which HTTP statuses count as healthy.
- Send alerts on state changes, not on every failed poll.
- Treat in-memory alert state as a starter approach; use persistent storage if restarts or multiple monitor instances matter.
1. Create the project and configure targets
Use Node.js 20 or later for the built-in fetch implementation. Install Nodemailer for SMTP email delivery:
npm init -y
npm install nodemailer
Set the package to use ECMAScript modules by adding "type": "module" to package.json. Configure the monitor with trusted environment variables:
MONITOR_URLS="https://example.com/health,https://status.example.net/"
MONITOR_ALLOWED_HOSTS="example.com,status.example.net"
CHECK_INTERVAL_MS="60000"
REQUEST_TIMEOUT_MS="8000"
MAX_RETRIES="2"
SMTP_HOST="smtp.example.net"
SMTP_PORT="587"
SMTP_SECURE="false"
SMTP_USER="monitor@example.net"
SMTP_PASS="replace-with-a-secret-from-your-secret-store"
ALERT_FROM="monitor@example.net"
ALERT_TO="on-call@example.net"
Use real endpoints that you are authorized to monitor. Store SMTP credentials in a secret manager or protected deployment environment; do not commit them to source control. The example uses a comma-separated list for readability, but a deployment system may provide configuration in another form.
2. Restrict which URLs the monitor can request
A monitor that accepts arbitrary URLs can become a Server-Side Request Forgery (SSRF) service. An attacker could try to make it request internal applications, cloud metadata endpoints, or other destinations that should not be exposed.
This example accepts targets only from trusted configuration and requires each target's hostname to match an explicit allowlist. It also requires HTTPS, rejects URL credentials and IP-address literals, and does not follow redirects. These checks reduce risk, but hostname validation alone is not a complete SSRF defense: DNS can resolve to unexpected addresses, and network routing may expose internal services. Keep target configuration administrator-controlled and enforce outbound network rules that block private, loopback, link-local, and other prohibited destinations. If you allow users to manage targets, add a purpose-built validation and egress-control design rather than relying on a URL parser or hostname check alone.
For a one-off manual inspection of a response code, TidyURLs' HTTP status checker may be useful. A manual check is not a replacement for scheduled monitoring or server-side network controls.
3. Implement the monitor
Create monitor.js:
import { isIP } from "node:net";
import { setTimeout as delay } from "node:timers/promises";
import nodemailer from "nodemailer";
function required(name) {
const value = process.env[name]?.trim();
if (!value) throw new Error(`Missing required environment variable: ${name}`);
return value;
}
function positiveInteger(name, fallback) {
const value = Number(process.env[name] ?? fallback);
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer`);
}
return value;
}
const checkIntervalMs = positiveInteger("CHECK_INTERVAL_MS", 60_000);
const timeoutMs = positiveInteger("REQUEST_TIMEOUT_MS", 8_000);
const maxRetries = Number(process.env.MAX_RETRIES ?? 2);
if (!Number.isInteger(maxRetries) || maxRetries < 0 || maxRetries > 5) {
throw new Error("MAX_RETRIES must be an integer from 0 to 5");
}
const allowedHosts = new Set(
required("MONITOR_ALLOWED_HOSTS")
.split(",")
.map((host) => host.trim().toLowerCase())
.filter(Boolean)
);
function loadTargets() {
const values = required("MONITOR_URLS")
.split(",")
.map((value) => value.trim())
.filter(Boolean);
if (values.length === 0) throw new Error("Configure at least one monitor URL");
return values.map((value) => {
const url = new URL(value);
const hostname = url.hostname.toLowerCase();
if (url.protocol !== "https:") {
throw new Error(`Monitor targets must use HTTPS: ${value}`);
}
if (url.username || url.password || url.hash) {
throw new Error(`Target must not contain credentials or a fragment: ${value}`);
}
if (isIP(hostname) || hostname === "localhost" || hostname.endsWith(".localhost") || hostname.endsWith(".local")) {
throw new Error(`IP literals and local hostnames are not allowed: ${value}`);
}
if (!allowedHosts.has(hostname)) {
throw new Error(`Target hostname is not in MONITOR_ALLOWED_HOSTS: ${hostname}`);
}
return url.toString();
});
}
const targets = loadTargets();
const transporter = nodemailer.createTransport({
host: required("SMTP_HOST"),
port: positiveInteger("SMTP_PORT", 587),
secure: process.env.SMTP_SECURE === "true",
auth: {
user: required("SMTP_USER"),
pass: required("SMTP_PASS"),
},
});
const alertFrom = required("ALERT_FROM");
const alertTo = required("ALERT_TO");
const state = new Map();
async function checkTarget(target) {
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
try {
const response = await fetch(target, {
method: "GET",
redirect: "manual",
signal: AbortSignal.timeout(timeoutMs),
headers: { "user-agent": "authorized-uptime-monitor/1.0" },
});
// This monitor checks response headers, not the page body.
try {
await response.body?.cancel();
} catch {
// A body-cancellation error does not change the received status code.
}
const status = response.status;
const ok = status >= 200 && status < 400;
const shouldRetry = status === 429 || status >= 500;
if (shouldRetry && attempt < maxRetries) {
const backoffMs = 500 * 2 ** attempt + Math.floor(Math.random() * 250);
await delay(backoffMs);
continue;
}
return { ok, status, error: null };
} catch (error) {
if (attempt === maxRetries) {
return { ok: false, status: null, error: error.message };
}
const backoffMs = 500 * 2 ** attempt + Math.floor(Math.random() * 250);
await delay(backoffMs);
}
}
return { ok: false, status: null, error: "Retries exhausted" };
}
async function sendAlert(target, isUp, result) {
const subject = isUp ? `Recovered: ${target}` : `Monitor reports a failure: ${target}`;
const detail = isUp
? "The target responded successfully again."
: result.status === null
? `Request failed: ${result.error}`
: `Received HTTP ${result.status}`;
await transporter.sendMail({
from: alertFrom,
to: alertTo,
subject,
text: `${target}\n${detail}\nChecked at: ${new Date().toISOString()}`,
});
}
async function checkAndReport(target) {
const result = await checkTarget(target);
const previous = state.get(target);
console.log(JSON.stringify({
target,
ok: result.ok,
status: result.status,
error: result.error,
checkedAt: new Date().toISOString(),
}));
// Record an initially healthy target without sending a recovery email.
if (previous === undefined && result.ok) {
state.set(target, true);
return;
}
if (previous === result.ok) return;
try {
await sendAlert(target, result.ok, result);
state.set(target, result.ok);
} catch (error) {
// Leave the old state unchanged so the next check can retry the notification.
console.error(`Could not send alert for ${target}: ${error.message}`);
}
}
let cycleRunning = false;
async function runCycle() {
if (cycleRunning) {
console.warn("Skipping cycle because the previous cycle is still running");
return;
}
cycleRunning = true;
try {
for (const target of targets) {
await checkAndReport(target);
}
} finally {
cycleRunning = false;
}
}
await runCycle();
setInterval(() => {
void runCycle().catch((error) => console.error("Monitor cycle failed:", error));
}, checkIntervalMs);
Start the process with node monitor.js. Run it under a process manager or deployment platform that restarts it if it exits, and make sure its environment contains the required configuration.
What the status policy means
The code treats HTTP 200 through 399 as healthy. It does not follow redirects: a 301 or 302 counts as a responding endpoint, but the destination is not checked. A 4xx response is considered a failure and is not retried; 429 and 5xx responses are retried within the configured limit.
Adjust this policy to fit the endpoint. For example, an API health endpoint might require exactly 200, while an application that intentionally redirects may treat certain 3xx codes as acceptable. A 401 or 403 proves that an HTTP server responded, but this example still considers it unhealthy because it does not meet the chosen application-level policy.
4. Timeouts, retries, and failure types
A timeout caps how long a single request can wait. Here, AbortSignal.timeout(timeoutMs) applies a separate timeout to each attempt, so total check time can exceed the timeout when retries occur. Keep the retry count low and the interval longer than the expected check duration.
Retries help with temporary network problems, but they can also add load and delay an alert. This implementation retries network errors, including timeouts and errors surfaced by DNS or TLS, plus HTTP 429 and 5xx responses. It uses exponential backoff with a small random delay. DNS and TLS failures usually appear as rejected fetch calls rather than HTTP statuses, so the monitor records the error and reports a failure only after the retry budget is exhausted. Error messages vary by Node.js version and should be treated as diagnostic text, not a stable classification API.
A 429 response may include a Retry-After header. This example does not interpret that header; if rate limits are expected, add bounded handling for it rather than retrying faster than the endpoint permits. Also consider a longer recovery threshold for noisy services, such as requiring two consecutive successful checks before sending a recovery alert.
5. Alert deduplication and scheduling trade-offs
The state map tracks whether each target was last considered up or down. Email is sent only when that state changes. If a target stays down, later failed polls do not send another message; when it becomes healthy, a recovery email is sent.
This state exists only in memory. A restart forgets it, and multiple monitor processes can independently send duplicate messages. For reliable deduplication across restarts or instances, store each target's state in a database or shared cache and update it atomically. You may also want to persist check history, alert delivery attempts, and the time of the last notification.
The scheduler avoids overlapping cycles in a single process. Targets are checked sequentially, so a large list can take a long time; if you need concurrency, use a bounded worker pool and retain the overlap guard. Unbounded parallel requests can overload your own monitor or the services it checks.
Common mistakes and troubleshooting
- Monitoring a login page instead of a health endpoint: authentication redirects or access-denied responses may not represent application health. Prefer an authorized endpoint designed for health checks.
- Treating any response as success: decide which status codes are healthy for your service, and make that policy explicit.
- Following redirects without validation: redirects can change the destination. This implementation does not follow them; if you add redirect support, revalidate every destination and preserve SSRF protections at each hop.
- Assuming hostname checks prevent SSRF: DNS resolution and network routing still matter. Keep targets trusted, restrict outbound traffic, and do not expose this script as an arbitrary-URL proxy.
- Sending an email for every failed poll: alert on state transitions or apply a documented escalation policy to reduce noise.
- Misreading DNS or TLS problems as HTTP errors: when no HTTP response arrives, there is no status code. Check DNS records, certificate validity, system time, and outbound connectivity from the monitor's own environment.
- Using a timeout that is too short: slow but healthy endpoints can appear unavailable. Choose a timeout based on the service's expected response time and monitor logs before changing thresholds.
- Assuming the monitor's location represents every user: a single monitor checks connectivity from one network location. It cannot establish that a site is reachable from all regions or from every browser.
Defensive recommendations
- Monitor only approved targets, and keep target configuration separate from untrusted input.
- Use an explicit hostname allowlist, prohibit unexpected redirects, and apply network egress rules that block internal address ranges.
- Use HTTPS and protect SMTP credentials. Restrict who can read logs and environment configuration.
- Keep retries bounded, set request timeouts, and avoid overlapping or unbounded checks.
- Persist state when duplicate or missed alerts across restarts are unacceptable. For higher availability, run independent monitors from controlled locations and define how their results are combined.
- Test the monitor against endpoints in an isolated environment that you control, including a slow response, a chosen error status, and a recovery.
Conclusion
A useful uptime monitor needs more than a repeated HTTP request. Timeouts, bounded retries, an explicit status policy, safe target restrictions, and state-change alerts make the checks more meaningful and less noisy. The implementation here is a practical starting point; add persistent state, egress controls, and operational monitoring as your reliability requirements grow.
