Build a Concurrent URL Health Checker CLI
A command-line tool that checks a list of URLs concurrently, follows redirects, records status and response time, retries transient failures, and prints a pass-fail summary.
Project goal
Build a CLI that checks many URLs concurrently with a bounded pool, reports status code and latency for each, retries transient errors with backoff, and exits non-zero when any check fails.
Final features
- Read URLs from arguments or a text file, one per line.
- Check all URLs concurrently with a configurable concurrency limit.
- Record status code and response time per URL and retry transient errors.
- Treat non-2xx responses and timeouts as failures with specific reasons.
- Print a table summary and exit non-zero when anything failed.
Architecture
An input parser yields a URL list; a checker performs one request with a timeout and classifies the outcome; a pool runs checks concurrently with a bound; and a reporter formats the results and computes the exit code. Retry logic wraps the single check so the pool stays simple.
Prerequisites
- Node.js 20 LTS or later
- Understanding of HTTP status classes and redirects
- Familiarity with concurrent task pools
Dependencies
- node >=20 — Global fetch, timers, and the CLI entry point
- vitest ^2.1.0 — Outcome classification and retry tests
Directory structure
health-check/
├── src/
│ ├── input.js
│ ├── check.js
│ ├── pool.js
│ ├── report.js
│ └── cli.js
├── test/
│ ├── check.test.js
│ └── pool.test.js
└── package.json
Phases
1. Input parsing
Collect URLs from arguments or a file, trim whitespace, and drop comments and blank lines.
Files: src/input.js
import { readFile } from 'node:fs/promises';
export async function loadUrls({ args = [], file }) {
const fromFile = file
? (await readFile(file, 'utf8')).split('\n').map((l) => l.trim())
: [];
const all = [...args, ...fromFile];
return all.filter((line) => line && !line.startsWith('#'));
}
export function validateUrl(url) {
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}Expected behavior: A file with blank lines and comments yields only the real URLs, and a non-http scheme fails validation.
2. Single check and classification
Perform one request with a timeout and classify the outcome as ok, client error, server error, timeout, or network failure.
Files: src/check.js
export function classify(status, latencyMs) {
const ok = status >= 200 && status < 300;
const retryable = status >= 500 || status === 429;
return { ok, status, latencyMs, retryable, reason: ok ? 'ok' : retryable ? 'server' : 'client' };
}
export async function checkOnce(url, { fetchImpl = fetch, timeoutMs = 5000 } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const started = Date.now();
try {
const res = await fetchImpl(url, { redirect: 'follow', signal: controller.signal });
return classify(res.status, Date.now() - started);
} catch (err) {
return { url, ok: false, retryable: err.name === 'AbortError', reason: err.name === 'AbortError' ? 'timeout' : 'network', latencyMs: Date.now() - started };
} finally {
clearTimeout(timer);
}
}Expected behavior: A 200 classifies ok, a 503 classifies retryable server, and an aborted request classifies timeout with no exception escaping.
3. Retry wrapper
Retry only outcomes classified as retryable, with a small backoff and a hard attempt cap.
Files: src/retry.js
export async function withRetry(fn, { attempts = 3, backoff = (n) => 100 * n } = {}) {
let last;
for (let i = 1; i <= attempts; i++) {
last = await fn();
if (last.ok || !last.retryable) return last;
if (i < attempts) await new Promise((r) => setTimeout(r, backoff(i)));
}
return last;
}Expected behavior: A retryable failure is attempted up to the cap and a non-retryable failure stops after one attempt, both asserted with an injected clock.
4. Concurrent pool
Run checks concurrently up to a limit and collect every outcome in input order.
Files: src/pool.js
export async function runChecks(urls, { concurrency = 8, runner } = {}) {
const results = new Array(urls.length);
let next = 0;
async function worker() {
while (next < urls.length) {
const index = next++;
results[index] = await runner(urls[index]);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, urls.length) }, worker));
return results;
}Expected behavior: Results retain input order regardless of completion order, and at most concurrency runners are active at once.
5. Reporter and exit code
Format a readable table of results, summarise pass and fail counts, and derive the process exit code.
Files: src/report.js
export function summarize(results) {
const passed = results.filter((r) => r.ok).length;
return {
total: results.length,
passed,
failed: results.length - passed,
exitCode: passed === results.length ? 0 : 1,
};
}
export function formatTable(results) {
return results.map((r) => {
const status = r.ok ? 'OK ' : 'FAIL';
return status + ' ' + String(r.status ?? '-').padStart(3) + ' ' + String(r.latencyMs) + 'ms ' + (r.url ?? '');
}).join('\n');
}Expected behavior: A run where one URL fails yields exit code 1, and the table prints one aligned line per URL with status and latency.
6. CLI and tests
Wire input, pool, retry, and reporting together, then cover classification, pooling, and exit codes with tests.
Files: src/cli.js
import { loadUrls } from './input.js';
import { checkOnce } from './check.js';
import { withRetry } from './retry.js';
import { runChecks } from './pool.js';
import { formatTable, summarize } from './report.js';
const args = parse(process.argv.slice(2));
const urls = await loadUrls(args);
const results = await runChecks(urls, {
concurrency: Number(args.concurrency ?? 8),
runner: (url) => withRetry(() => checkOnce(url, { timeoutMs: Number(args.timeout ?? 5000) })),
});
console.log(formatTable(results));
const summary = summarize(results);
console.error('passed ' + summary.passed + '/' + summary.total);
process.exit(summary.exitCode);Expected behavior: Running against a healthy URL exits 0 and prints an OK line; a failing URL exits 1 and appears as FAIL in the table.
Testing
Run npx vitest. Classification tests assert that 200 is ok, 404 is a client failure, 500 is retryable, and a timeout is a failure with reason timeout. Pool tests assert that concurrency never exceeds the bound and that every URL is visited once. A report test asserts the exit code is non-zero when any outcome failed.
Troubleshooting
Checks take far longer than the timeouts suggest
Bound concurrency and abort each request on its own timer; without a per-request abort, one hanging server can stall the whole run.
A 404 causes pointless retries
Classify by status before retrying; only 5xx and 429 are transient, and retrying a permanent client error just slows the run.
Results come back in completion order, not input order
Pre-size the results array and write each outcome at its input index; the pool should preserve order even though completion does not.
Deployment
Run from a cron job or a CI smoke-test step and act on the exit code. Because concurrency and timeout are flags, the same binary serves a quick pre-deploy gate and a broader nightly sweep.
Known limitations
- Only status and latency are captured, not response content or TLS details.
- Redirects are followed silently, so a redirect loop that hits the timeout is reported as a timeout rather than a loop.
Project overview
Health checking a set of URLs is mostly about classification: what counts as healthy, what is retryable, and how long to wait before giving up. This project makes those rules explicit and testable by injecting the fetch function.
The checker runs bounded-concurrent requests, records latency, retries transient failures, and prints a summary that a cron job can act on through its exit code.
The project is deliberately sized so that every stage ships something you can run: the first phase produces a working command or endpoint, and each later phase adds one capability without forcing a rewrite of what came before. That property — always runnable, never half-migrated — is what makes the tutorial usable as a weekend build rather than a sketch that only works once the final commit lands.
The finished tool carries no hidden services and no hosted dependencies. Everything it needs lives in the repository and a machine with the stated runtime, so a reader can clone, install, run the tests, and trust that the behaviour they see locally is the behaviour the tutorial promised. Where a real deployment needs an external system, that seam is called out explicitly rather than smuggled in through a default.
It is written for a working developer who wants the whole shape of a real tool in one readable pass rather than a tour of a framework. Each phase states the decision it makes and why, then shows the code that encodes it, so the reader can stop after any phase with something that runs and resume later without relearning the whole design. The prose is deliberately honest about trade-offs: where a simpler approach would break at scale, that ceiling is named instead of hidden behind a benchmark.
One check is a function that returns a classification rather than throwing, which keeps the pool free of exception handling:
export async function checkOnce(url, { fetchImpl = fetch, timeoutMs = 5000 } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const started = Date.now();
try {
const res = await fetchImpl(url, { redirect: 'follow', signal: controller.signal });
const latencyMs = Date.now() - started;
return classify(res.status, latencyMs);
} catch (err) {
return { url, ok: false, reason: err.name === 'AbortError' ? 'timeout' : 'network', latencyMs: Date.now() - started };
} finally {
clearTimeout(timer);
}
}
Returning an outcome object instead of throwing means the pool never needs a try block and the report is a pure fold over outcomes.
Setup
The tool uses the global fetch available in modern Node so there is no HTTP client dependency to install or version.
Set up the workspace before writing any logic. The table below pins every dependency to a version so the behaviour you observe matches the tutorial; floating versions are the most common reason a reader’s output diverges from the text. Install exactly what the table lists, then confirm the toolchain reports the expected version before continuing.
Two configuration choices deserve attention up front. First, the project keeps its runtime entry and its test entry as separate scripts so that running tests never starts a server or writes to a data directory. Second, all tunables are read from one place — a small config module or environment variables — so that nothing depends on the working directory a command happens to be launched from.
| Dependency | Version | Why it is here |
|---|---|---|
| node | >=20 | Global fetch, timers, and the CLI entry point |
| vitest | ^2.1.0 | Outcome classification and retry tests |
Create the project and install:
mkdir health-check && cd health-check
npm init -y
npm install -D vitest@^2.1.0
Wire the CLI and tests in package.json:
{
"name": "health-check",
"type": "module",
"scripts": {
"check": "node src/cli.js",
"test": "vitest run"
}
}
Tests inject a fake fetch so classifications and retries are verified without any network access.
Phase 1: Input parsing
Collect URLs from arguments or a file, trim whitespace, and drop comments and blank lines. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
The first hard decision is where the boundary between parsing and doing sits. Input arrives as raw text — argv, environment variables, a file on disk — and this phase converts it exactly once into a typed object that the rest of the program trusts. Confining that conversion to a single module means every later layer receives clean, already-validated input and never re-checks a string, which removes a whole class of bugs where two modules quietly disagree about what a flag or a field means. The second decision is failure visibility: invalid input must produce an actionable message and a non-zero exit rather than a stack trace surfacing from three layers down. Getting these two boundaries right early is what lets the remaining phases stay small.
import { readFile } from 'node:fs/promises';
export async function loadUrls({ args = [], file }) {
const fromFile = file
? (await readFile(file, 'utf8')).split('\n').map((l) => l.trim())
: [];
const all = [...args, ...fromFile];
return all.filter((line) => line && !line.startsWith('#'));
}
export function validateUrl(url) {
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
Acceptance for this phase: A file with blank lines and comments yields only the real URLs, and a non-http scheme fails validation.
Phase 2: Single check and classification
Perform one request with a timeout and classify the outcome as ok, client error, server error, timeout, or network failure. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
This phase defines the data model that every other module speaks. The choice of shape matters more than it looks: flat records are easy to serialize and diff, nested structures model relationships but complicate equality checks, and identifiers must be stable across edits so that references never silently rebind. The code here stays pure — functions take data and return data, with no clocks, no disk, and no printing — because pure functions are the only kind that can be tested exhaustively without a fixture harness. When the model is right the surrounding code almost writes itself; when it is wrong every later phase pays interest.
export function classify(status, latencyMs) {
const ok = status >= 200 && status < 300;
const retryable = status >= 500 || status === 429;
return { ok, status, latencyMs, retryable, reason: ok ? 'ok' : retryable ? 'server' : 'client' };
}
export async function checkOnce(url, { fetchImpl = fetch, timeoutMs = 5000 } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const started = Date.now();
try {
const res = await fetchImpl(url, { redirect: 'follow', signal: controller.signal });
return classify(res.status, Date.now() - started);
} catch (err) {
return { url, ok: false, retryable: err.name === 'AbortError', reason: err.name === 'AbortError' ? 'timeout' : 'network', latencyMs: Date.now() - started };
} finally {
clearTimeout(timer);
}
}
Acceptance for this phase: A 200 classifies ok, a 503 classifies retryable server, and an aborted request classifies timeout with no exception escaping.
Phase 3: Retry wrapper
Retry only outcomes classified as retryable, with a small backoff and a hard attempt cap. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
The core operation is where the project delivers its value, and the temptation is to cram every option into one function. Resist it. The main path should read as a short, obvious sequence — load, transform, emit — with the branching behaviour factored into small decisions that can be tested on their own. This phase also fixes the error contract: which failures are fatal, which are recoverable, and how a partial result is reported. A tool that can explain what it did and why it stopped is far easier to trust in a pipeline than one that only signals success or silence.
export async function withRetry(fn, { attempts = 3, backoff = (n) => 100 * n } = {}) {
let last;
for (let i = 1; i <= attempts; i++) {
last = await fn();
if (last.ok || !last.retryable) return last;
if (i < attempts) await new Promise((r) => setTimeout(r, backoff(i)));
}
return last;
}
Acceptance for this phase: A retryable failure is attempted up to the cap and a non-retryable failure stops after one attempt, both asserted with an injected clock.
Phase 4: Concurrent pool
Run checks concurrently up to a limit and collect every outcome in input order. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
Integration is where the project stops being a toy. Input may arrive in a format an external system produced, output may need to match a contract another system consumes, and the two rarely line up without adjustment. This phase owns the adapters — the small translation functions between the outside world’s shape and the internal model — and it owns the boundary decisions about what to do when the two disagree: coerce, reject, or warn and continue. Keeping adapters thin and explicit means a change to an external format touches one file, and the internal model stays stable as the ecosystem around it moves.
export async function runChecks(urls, { concurrency = 8, runner } = {}) {
const results = new Array(urls.length);
let next = 0;
async function worker() {
while (next < urls.length) {
const index = next++;
results[index] = await runner(urls[index]);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, urls.length) }, worker));
return results;
}
Acceptance for this phase: Results retain input order regardless of completion order, and at most concurrency runners are active at once.
Phase 5: Reporter and exit code
Format a readable table of results, summarise pass and fail counts, and derive the process exit code. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
The user-facing surface determines whether the project feels finished. A command-line tool owes its caller predictable stdout, meaningful stderr, and honest exit codes; a service owes a stable request and response schema plus consistent status handling. This phase wires the internal modules to that surface and nothing else — it contains no business logic of its own, only translation between the outside contract and the internal calls. That separation is what lets the same core serve a CLI today and an HTTP endpoint tomorrow without duplication, and it is why the tests for this layer assert process-level behaviour rather than poking at internals.
export function summarize(results) {
const passed = results.filter((r) => r.ok).length;
return {
total: results.length,
passed,
failed: results.length - passed,
exitCode: passed === results.length ? 0 : 1,
};
}
export function formatTable(results) {
return results.map((r) => {
const status = r.ok ? 'OK ' : 'FAIL';
return status + ' ' + String(r.status ?? '-').padStart(3) + ' ' + String(r.latencyMs) + 'ms ' + (r.url ?? '');
}).join('\n');
}
Acceptance for this phase: A run where one URL fails yields exit code 1, and the table prints one aligned line per URL with status and latency.
Phase 6: CLI and tests
Wire input, pool, retry, and reporting together, then cover classification, pooling, and exit codes with tests. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.
The final phase turns a working program into a dependable one. Tests are the visible part, but the discipline behind them is deeper: every claim the earlier phases made is pinned by an executable check, and every failure mode the tutorial mentioned has a test that reproduces it before the fix. Edge cases — empty input, malformed records, concurrent invocations, interrupted writes — are enumerated and asserted rather than assumed away. The payoff is a suite that fails loudly and precisely when someone changes a decision, which is exactly the signal a maintainer wants the moment they touch the code.
import { loadUrls } from './input.js';
import { checkOnce } from './check.js';
import { withRetry } from './retry.js';
import { runChecks } from './pool.js';
import { formatTable, summarize } from './report.js';
const args = parse(process.argv.slice(2));
const urls = await loadUrls(args);
const results = await runChecks(urls, {
concurrency: Number(args.concurrency ?? 8),
runner: (url) => withRetry(() => checkOnce(url, { timeoutMs: Number(args.timeout ?? 5000) })),
});
console.log(formatTable(results));
const summary = summarize(results);
console.error('passed ' + summary.passed + '/' + summary.total);
process.exit(summary.exitCode);
Acceptance for this phase: Running against a healthy URL exits 0 and prints an OK line; a failing URL exits 1 and appears as FAIL in the table.
Design trade-offs
Minimal dependencies over framework convenience. A framework would hand over plumbing for free, but it would also own the interesting decisions this project exists to teach. Keeping the dependency surface small means the lifecycle is visible end to end, the install is instant, and the tool runs anywhere the stated runtime does. The cost is owning the edge cases yourself — which is precisely why each of them gets a test.
Explicit contracts over clever conventions. Where a convention would infer behaviour from a filename or a folder, the project asks for a named export or a declared field instead. It reads as slightly more verbose and is far easier to review: a reader sees the intent on the line that owns it rather than reconstructing it from directory layout. Debugging also gets cheaper, because the failure points at the declaration instead of the convention that silently produced it.
Deterministic behaviour over convenience defaults. Anything that could vary between machines — time, ordering, encoding, locale — is injected or pinned rather than read from the ambient environment. That choice costs a little ceremony and buys reproducible output, which is the property that makes the output diffable in review and the tests trustworthy across the team’s machines.
Testing strategy
Run npx vitest. Classification tests assert that 200 is ok, 404 is a client failure, 500 is retryable, and a timeout is a failure with reason timeout. Pool tests assert that concurrency never exceeds the bound and that every URL is visited once. A report test asserts the exit code is non-zero when any outcome failed.
Tests are organised by layer so a failure points at the decision that broke. Pure unit tests cover the transformation logic without touching disk or network and run in milliseconds; integration tests exercise the real entry point against a temporary working directory and assert the observable contract; a small end-to-end tier proves the whole path produces the documented output. Keeping the fast tiers large and the slow tier thin is what lets the suite run on every save while still catching integration mistakes.
Every test asserts a specific promise from the phases above rather than merely covering lines. A passing suite therefore means the tool kept its word: filters compose, identifiers stay stable, failures exit non-zero with a readable message, and the output matches the format a downstream consumer expects. When someone changes a contract the matching test fails with an exact diff, turning a silent regression into a line in the pull request.
Running the suite in CI is the last line of defence, but the same command runs locally before every commit, which is what keeps the feedback loop short. A green run means the documented behaviour still holds; a red run names the exact promise that broke. Treat the suite as executable documentation and it stays current, because a stale assertion fails the moment the code and the text disagree.
Retry classification is where the value lives: retrying a 404 wastes time, and not retrying a 503 hides a real outage, so both directions get a test.
Extending the project
Asserting response bodies, measuring time-to-first-byte, or checking TLS certificates are additional fields on the outcome. A YAML input file with per-URL expectations turns the tool into a smoke-test runner without changing the pool.
The structure leaves obvious seams for growth. Each capability that could be added — a new output format, an extra filter, an interactive mode — lands in one layer because the boundaries between parsing, logic, and presentation are already drawn. Extensions that would break the current design are equally visible: the moment a feature needs cross-process coordination or a database, the storage interface is where the change belongs, and its tests tell you immediately whether the surrounding code survives the swap.