ScreenshotNeo

BlogHow-to

How to Build a Website Monitoring Script in Node.js

Build a Node.js website monitor that checks status, measures latency, enforces timeouts, and reports failures with runnable code.

By the ScreenshotNeo team1 October 20269 min read

A useful website monitor does more than see whether fetch() completed. It checks the HTTP status, measures elapsed time, cancels requests that exceed a limit, and records network or application failures.

This guide builds a small periodic monitor with Node.js’s built-in fetch. It accepts the target URL from configuration, supports an expected content marker, reports latency, and handles timeouts, DNS errors, TLS errors, and non-healthy HTTP responses.

What the monitor checks

  • HTTP status: A fulfilled fetch can still represent a 404 or 500. The script explicitly evaluates the response status.
  • Latency: Each result includes elapsed milliseconds so slow responses are distinguishable from outright failures.
  • Timeout: An AbortSignal cancels a request after the configured limit. An expired request is a failed check.
  • Optional content: A stable marker, such as a page title or health-check string, can detect an incorrect page behind a successful status.
  • Repeat interval: The script runs checks on a deliberate cadence and prevents overlapping checks.

Prerequisites and project setup

  1. Install a current Node.js release with the built-in fetch API. AbortSignal.timeout() was added in Node.js 17.3.0 and 16.14.0; verify the runtime used by your deployment.
  2. Create a directory and initialize a package:
mkdir node-website-monitor
cd node-website-monitor
npm init -y

Save the monitor as monitor.mjs. The example uses ECMAScript modules, so the .mjs extension works without changing package.json.

A complete Node.js monitoring script

#!/usr/bin/env node

const config = {
  url: process.env.MONITOR_URL,
  intervalMs: Number(process.env.MONITOR_INTERVAL_MS || 60_000),
  timeoutMs: Number(process.env.MONITOR_TIMEOUT_MS || 10_000),
  // Comma-separated values, for example: 200,204,301,302
  healthyStatuses: new Set(
    (process.env.MONITOR_HEALTHY_STATUSES || '200-299')
      .split(',')
      .flatMap((part) => {
        const value = part.trim();
        if (/^\d{3}-\d{3}$/.test(value)) {
          const [from, to] = value.split('-').map(Number);
          return Array.from({ length: to - from + 1 }, (_, i) => from + i);
        }
        return /^\d{3}$/.test(value) ? [Number(value)] : [];
      }),
  ),
  expectedText: process.env.MONITOR_EXPECTED_TEXT || '',
};

if (!config.url) {
  console.error('Set MONITOR_URL to the URL you want to monitor.');
  process.exitCode = 2;
  process.exit();
}

function errorDetails(error) {
  if (error?.name === 'TimeoutError' || error?.name === 'AbortError') {
    return { type: 'timeout', message: `request exceeded ${config.timeoutMs} ms` };
  }
  return {
    type: error?.name || 'Error',
    message: error?.message || String(error),
  };
}

async function checkOnce() {
  const started = performance.now();
  const checkedAt = new Date().toISOString();
  let response;

  try {
    const signal = AbortSignal.timeout(config.timeoutMs);
    response = await fetch(config.url, {
      method: 'GET',
      redirect: 'follow',
      signal,
      headers: {
        'user-agent': 'node-website-monitor/1.0',
        accept: 'text/html,application/xhtml+xml',
      },
    });

    const elapsedMs = Math.round(performance.now() - started);
    const statusHealthy = config.healthyStatuses.has(response.status);
    let contentHealthy = true;

    if (config.expectedText) {
      const body = await response.text();
      contentHealthy = body.includes(config.expectedText);
    }

    const healthy = statusHealthy && contentHealthy;
    const result = {
      checkedAt,
      url: config.url,
      healthy,
      status: response.status,
      statusText: response.statusText,
      elapsedMs,
      statusHealthy,
      contentHealthy,
    };

    console.log(JSON.stringify(result));
    return result;
  } catch (error) {
    const elapsedMs = Math.round(performance.now() - started);
    const details = errorDetails(error);
    const result = {
      checkedAt,
      url: config.url,
      healthy: false,
      elapsedMs,
      error: details,
    };

    console.error(JSON.stringify(result));
    return result;
  }
}

let running = false;
let stopped = false;

async function runScheduledCheck() {
  if (running || stopped) return;
  running = true;
  try {
    await checkOnce();
  } finally {
    running = false;
  }
}

await runScheduledCheck();
const timer = setInterval(runScheduledCheck, config.intervalMs);

a function stop() {
  stopped = true;
  clearInterval(timer);
}

process.on('SIGINT', stop);
process.on('SIGTERM', stop);

Replace the accidental a function stop() line with function stop() before running. The corrected ending is:

function stop() {
  stopped = true;
  clearInterval(timer);
}

process.on('SIGINT', stop);
process.on('SIGTERM', stop);

Run it with:

MONITOR_URL=https://example.com \
MONITOR_INTERVAL_MS=60000 \
MONITOR_TIMEOUT_MS=10000 \
node monitor.mjs

Every successful or unsuccessful check produces a JSON record. Redirect the output to a file, a process supervisor, or your logging system.

Understanding the implementation

Check the status explicitly

fetch() fulfills its promise when the response status and headers arrive. An HTTP 404 or 500 is still a fulfilled response, so the script tests response.status against the configured healthy set. The default accepts every 2xx status.

Some endpoints intentionally redirect or return 204. Configure those statuses rather than assuming that only 200 is healthy:

MONITOR_HEALTHY_STATUSES=200,204,301,302 node monitor.mjs

Measure elapsed time

The timer starts immediately before the request and ends after the response arrives. If an expected marker is configured, the body read is included because the check is not complete until the content assertion finishes.

Use a real cancellation timeout

AbortSignal.timeout(config.timeoutMs) creates a signal that aborts after the selected delay. The catch block reports both TimeoutError and AbortError as a failed timeout.

Do not confuse this with the lower-level node:http timeout option. Node.js documents that setting timeout or calling setTimeout() adds timeout behavior or notification but does not abort the request by itself. Pass an AbortSignal or explicitly destroy the request.

Test content when status is not enough

Set a stable marker only when it is part of the endpoint contract:

MONITOR_URL=https://example.com/health \
MONITOR_EXPECTED_TEXT='healthy' \
node monitor.mjs

A content check can catch a branded error page, an accidental login redirect, or a proxy returning the wrong application. Avoid matching volatile text such as timestamps.

Configuration reference

Variable Default Purpose
MONITOR_URL required Target URL.
MONITOR_INTERVAL_MS 60000 Delay between scheduled checks.
MONITOR_TIMEOUT_MS 10000 Maximum request duration before cancellation.
MONITOR_HEALTHY_STATUSES 200-299 Comma-separated status codes or ranges.
MONITOR_EXPECTED_TEXT empty Optional exact substring required in the response body.

One-shot checks and exit codes

For cron or a container scheduler, remove the interval and return a process status based on the result. Add this small entry point instead of starting setInterval:

const result = await checkOnce();
process.exitCode = result.healthy ? 0 : 1;

A one-shot process is often simpler for cron, Kubernetes Jobs, or a CI runner. A long-running process is convenient when you already have a supervisor that restarts it and collects stdout.

Using the lower-level node:http API

Built-in fetch is the simplest option. Use node:http when you need lower-level socket behavior or compatibility with an older runtime. The important detail is still explicit cancellation:

import http from 'node:http';

export function checkWithHttp(url, timeoutMs = 10_000) {
  return new Promise((resolve) => {
    const started = performance.now();
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeoutMs);

    const request = http.get(url, { signal: controller.signal }, (response) => {
      response.resume();
      response.once('end', () => {
        clearTimeout(timer);
        resolve({
          healthy: response.statusCode >= 200 && response.statusCode < 300,
          status: response.statusCode,
          elapsedMs: Math.round(performance.now() - started),
        });
      });
    });

    request.once('error', (error) => {
      clearTimeout(timer);
      resolve({
        healthy: false,
        elapsedMs: Math.round(performance.now() - started),
        error: { type: error.name, message: error.message },
      });
    });
  });
}

Calling request.setTimeout() alone does not cancel the request. If you use it, handle the timeout event and destroy or abort the request yourself.

Retries, cadence, and alerting

A single failed request can be caused by a transient network path, DNS lookup, deploy, or overloaded origin. Decide whether one failed attempt should alert immediately or whether a policy such as “two failures in a row” fits your service.

  • Keep the per-attempt timeout finite.
  • Use a small number of retries with backoff when a transient failure is expected.
  • Do not retry indefinitely; it hides an outage and increases load.
  • Record every attempt, including status, latency, error type, and timestamp.
  • Send alerts from the monitoring process or from a log/metrics system. The correct destination depends on your deployment.

Retries should not replace history. Persist results if you need uptime calculations, latency percentiles, incident timelines, or team dashboards.

Scheduling and deployment choices

Environment Pattern Considerations
Local development Run node monitor.mjs Useful for debugging; the terminal is not durable alerting.
cron or a scheduler One-shot check with exit code Scheduler controls cadence and captures failures.
Container or VM Long-running interval Use a supervisor and forward stdout/stderr to durable logs.
Serverless job One-shot check Keep configuration in environment variables and let the platform invoke it.

Run checks from a location that can reach the target and, when relevant, from more than one network region. A local process cannot establish global availability.

Performance, reliability, and cost notes

  • Performance: A basic check makes one request per interval. Reading the body for a content assertion adds transfer and parsing work, so use a small health endpoint when you control the application.
  • Reliability: Treat DNS, connection, TLS, redirect, abort, and body-read errors as failures. Log the error class without exposing secrets or authorization headers.
  • Overlap: The example skips a scheduled run while the previous one is active. This prevents a slow endpoint from creating an unbounded request pileup.
  • Clock and timestamps: Use a monotonic timer such as performance.now() for duration and an ISO timestamp for records.
  • Cost: The script itself has no service charge, but it consumes compute, bandwidth, and any scheduler or logging budget. More frequent checks and body downloads increase those costs.

Troubleshooting

Symptom Cause Fix
The script says healthy for a 404 or 500. The code only awaited fetch. Inspect response.status and compare it with an explicit healthy set.
A request hangs forever. No abort signal or finite timeout. Pass AbortSignal.timeout() or abort a controller after a deadline.
node:http emits a timeout but keeps running. setTimeout() and the timeout option do not abort automatically. Handle the event and call abort() or destroy the request.
TypeError: fetch failed. DNS, connection, TLS, proxy, or another network failure. Inspect the underlying error, verify DNS and certificates, and test the URL from the monitor’s network.
Every check times out. Timeout is below normal latency, the host is unreachable, or a proxy blocks the request. Test with curl, verify outbound access, and choose a deadline that leaves room for normal responses.
Status is healthy but content fails. The marker is absent, escaped differently, or the endpoint returned a different locale or variant. Choose a stable marker and inspect the actual response body; do not match volatile text.
Checks run twice at once. Interval is shorter than request duration and no overlap guard exists. Keep the running guard or schedule the next run after the previous one completes.
The process stops after a deploy. No process supervisor or durable scheduler. Run it under your platform’s supervisor and persist logs externally.
AbortSignal.timeout is undefined. The Node.js runtime is too old. Upgrade Node.js or create an AbortController and call abort() from a timer.

Or skip the browser setup

If your monitoring goal includes a visual capture, you do not need to install or maintain a headless browser. ScreenshotNeo provides a website screenshot API and MCP server. The same GET request returns a PNG, JPEG, WebP, or PDF; the API documentation is at screenshotneo.com/docs/.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed as clean shots; the response identifies the page verdict and billing through X-Page-Verdict and X-Billed headers. Its MCP server lets Claude, Cursor, and other MCP clients call take_screenshot, get_page_info, and capture_pdf. The Free plan includes 1,000 screenshots a month with no card, and paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

cURL probe for debugging

Before debugging Node.js, confirm that the endpoint responds from the same environment:

curl -I --max-time 10 https://example.com

The headers show the HTTP status and redirect behavior, but they do not replace the script’s content check or application-specific health logic.

FAQ

How do I check if a website is up with Node.js?

Send a request, inspect the returned status code, measure elapsed time, and treat network errors or an expired timeout as failures. A fulfilled fetch promise alone is not enough.

Should a redirect count as healthy?

That depends on the endpoint contract. Follow redirects by default, then include the final status in your healthy set or reject redirects when a redirect indicates misconfiguration.

How often should the monitor run?

Choose a cadence based on how quickly you need to detect an incident, the endpoint’s rate limits, and your compute and logging budget. There is no universal interval.

Is this a full uptime-monitoring service?

No. It is a small checker. Durable history, multi-region probes, alert routing, dashboards, escalation, and incident management require additional components or a monitoring service.

Can I monitor a page’s visual appearance?

The Node.js script checks HTTP and optional text content. For rendered screenshots, cookie handling, popup removal, PDFs, or AI-agent workflows, use the ScreenshotNeo request shown above.