When we set out to add live health data to DeveloperLab's API directory, the goal was simple: every API card should show a real HTTP status code and a server-measured latency — not a stale badge copied from a vendor's status page. What we didn't expect was how quickly "simple" became "concurrency problem."
The Naive Approach (And Why It Failed)
The first prototype made sequential fetch calls. One endpoint after another, in a tight loop. With 199 APIs in our catalogue, a single slow endpoint (looking at you, some government data portals) would stall the entire probe run for up to 30 seconds — blocking the event loop and making the server feel sluggish during the sweep.
Serial probe for 199 endpoints × avg 400 ms = ~80 seconds per run
That's completely unacceptable for a tool that's supposed to provide live data.
Batched Concurrent Probing
The architecture we settled on runs probes in batches of 25 concurrent requests, each with a hard 5-second timeout. Node.js's non-blocking I/O handles concurrency elegantly here — 25 in-flight fetch calls don't spin up 25 threads; they issue 25 network requests and yield control back to the event loop.
const BATCH_SIZE = 25;
const TIMEOUT_MS = 5000;
async function probeBatch(entries: ApiEntry[]): Promise<void> {
await Promise.allSettled(
entries.map(async (entry) => {
const start = Date.now();
try {
const res = await fetch(entry.Link, {
signal: AbortSignal.timeout(TIMEOUT_MS),
redirect: "follow",
});
entry.last_checked_status = res.status;
entry.latency_ms = Date.now() - start;
entry.response_headers = {
server: res.headers.get("server") ?? undefined,
"content-type": res.headers.get("content-type") ?? undefined,
"cache-control": res.headers.get("cache-control") ?? undefined,
};
} catch {
entry.last_checked_status = null;
entry.latency_ms = null;
}
})
);
}
Promise.allSettled is critical here. Unlike Promise.all, it never short-circuits on a rejected promise. One timed-out endpoint doesn't kill the rest of the batch.
Slicing Into Batches
async function startApiHealthChecker(): Promise<void> {
const entries = loadStoredData().entries;
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
await probeBatch(entries.slice(i, i + BATCH_SIZE));
}
saveStoredData({ entries, lastUpdated: new Date().toISOString() });
}
With 199 entries, that's 8 batches. Worst case (all 25 endpoints in a batch hit the 5-second timeout), a single batch takes 5 seconds. Total worst-case sweep: 40 seconds. Typical real-world sweep: under 30 seconds.
The Numbers After One Week
| Metric | Value |
|---|---|
| Total endpoints probed | 199 |
| Average sweep duration | 23 seconds |
| Consistently reachable | 192/199 (96.5%) |
| 7 unreachable | Mostly government/academic APIs with Cloudflare blocks |
| Probe interval | 20 minutes |
The 7 endpoints that show as unreachable aren't down — they're blocking our probe's User-Agent or requiring cookies. We mark those with a null status rather than reporting them as errors to avoid misleading users.
Persisting Health Data Without a Database
We write health fields (latency_ms, last_checked_status, response_headers, last_health_check) directly back into public_apis.json after every sweep. No PostgreSQL. No Redis. No cloud infrastructure.
This means:
- Zero-latency reads (the file is already in memory)
- Atomic writes (we replace the entire JSON file)
- Trivial deployment (it's just a file)
The tradeoff: health data is lost on server restart. In practice, the first probe completes 30 seconds after startup, which is acceptable.
What's Next
We're exploring delta updates — only re-probing endpoints whose status changed in the previous sweep, plus a random 20% sample to catch intermittent failures. This would cut sweep time to under 5 seconds for most runs.
The source for the health checker lives in src/lib/apiHealthChecker.ts. If you have questions about the implementation or spot an API that's incorrectly flagged, open an issue or send feedback via the footer.