How to check your resource links for breakage with a Node.js 24 script
Build a dependency-free link checker in Node.js 24 that extracts https links from a Markdown file, checks them in parallel with timeouts, reports redirects and failures, and runs weekly in GitHub Actions.
Updated 9 min read

In this article
If you keep a list of learning resources, documentation links or recommended tools (in a README, a team wiki or a Markdown file in your repository), some of those links will break. Pages move, projects are archived and domains expire. Readers then hit a 404 or, worse, a domain that now belongs to someone else.
In this tutorial you will build a small link checker with Node.js 24 and no third-party packages. It reads a text or Markdown file, extracts every https:// link, checks them in parallel with a time limit, reports redirects and failures, and exits with a non-zero status when something is broken, so it can run in CI. At the end you will schedule it to run weekly with GitHub Actions.
We wrote and ran the script on 7 October 2026 with Node.js v24.19.0. It uses only APIs documented as stable in Node.js 24: the global fetch, AbortSignal.timeout(), util.parseArgs() and fs/promises.

What you need
- Node.js 24 or later. The Node.js releases page listed Node.js 24 (Krypton) as an LTS line on the day we checked. Run
node -vto confirm your version. - A text or Markdown file with links. We will use a file called
resources.md. - Optionally, a GitHub repository if you want the scheduled check at the end.
The global fetch function is no longer experimental as of Node.js v21.0.0, according to the Node.js globals documentation, so you do not need node-fetch or axios for this.
Design decisions before writing code
A link checker looks trivial, but a naive version produces false alarms and can be slow. These are the choices the script makes and why:
| Decision | Reason |
|---|---|
Try HEAD first, then fall back to GET | HEAD avoids downloading the page. Some servers reject HEAD with 405 or 403, or do not implement it (501), so those statuses are retried with GET. |
| Follow redirects but report them | A redirect is not broken, but it tells you the bookmark should be updated before the old address disappears. |
| A timeout per request | A server that never answers should not stall the whole run. AbortSignal.timeout() cancels the request after a set time. |
| Limited concurrency | Checking 200 links at once can trigger rate limits. A small pool of workers (5 by default) is polite and still fast. |
| Cancel the response body | We only need the status. Cancelling the body releases the connection instead of leaving it open until it is garbage-collected. |
| Identify yourself | A descriptive User-Agent with a contact URL helps site owners understand the traffic. |
Only check https:// links | Plain http:// links in a resource list are worth replacing anyway; you can extend the pattern if you need them. |
Step 1: create a sample file
Create resources.md with a mix of working links, a missing page and a host that does not exist (the .invalid top-level domain is reserved and never resolves):
# My bookmarks
- MDN: https://developer.mozilla.org/en-US/
- PHP manual (https://www.php.net/manual/en/).
- Node.js docs: https://nodejs.org/docs/latest/api/
- Old link: https://nodejs.org/this-page-does-not-exist
- Redirect: http://github.com/ and https://github.com/education
- Bad host: https://no-such-host.invalid/Note the link inside parentheses with a full stop after it. Real Markdown is messy, and the extractor needs to cope with trailing punctuation and closing brackets.
Step 2: the script
Save the following as check-links.mjs. The .mjs extension tells Node.js to treat the file as an ES module, which allows top-level await.
// check-links.mjs: check every https:// link in a text or Markdown file.
// Usage: node check-links.mjs resources.md [--concurrency=5] [--timeout=10000] [--json]
import { readFile } from 'node:fs/promises';
import { parseArgs } from 'node:util';
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
concurrency: { type: 'string', default: '5' },
timeout: { type: 'string', default: '10000' },
json: { type: 'boolean', default: false },
},
});
if (positionals.length !== 1) {
console.error('Usage: node check-links.mjs <file> [--concurrency=5] [--timeout=10000] [--json]');
process.exit(2);
}
const concurrency = Math.max(1, Number.parseInt(values.concurrency, 10) || 5);
const timeoutMs = Math.max(1000, Number.parseInt(values.timeout, 10) || 10000);
const userAgent = 'link-checker/1.0 (+https://example.com/contact)';
const text = await readFile(positionals[0], 'utf8');
// Stop at whitespace and characters that usually end a URL in Markdown or HTML.
const found = text.match(/https:\/\/[^\s<>"'`)\]]+/g) ?? [];
const urls = [...new Set(found.map((u) => u.replace(/[.,;:!?]+$/, '')))];
async function request(url, method) {
const res = await fetch(url, {
method,
redirect: 'follow',
headers: { 'user-agent': userAgent, accept: 'text/html,*/*;q=0.8' },
signal: AbortSignal.timeout(timeoutMs),
});
// Release the connection; we never need the body.
await res.body?.cancel();
return res;
}
async function check(url) {
const started = performance.now();
try {
let res = await request(url, 'HEAD');
// Some servers reject or mishandle HEAD; retry those with GET.
if (res.status === 405 || res.status === 403 || res.status === 501) {
res = await request(url, 'GET');
}
return {
url,
status: res.status,
ok: res.ok,
finalUrl: res.redirected ? res.url : null,
ms: Math.round(performance.now() - started),
};
} catch (err) {
const reason = err.name === 'TimeoutError' ? `timeout after ${timeoutMs} ms` : (err.cause?.code ?? err.message);
return { url, status: null, ok: false, error: reason, ms: Math.round(performance.now() - started) };
}
}
// A small worker pool: at most `concurrency` requests in flight.
const results = new Array(urls.length);
let next = 0;
async function worker() {
while (next < urls.length) {
const i = next++;
results[i] = await check(urls[i]);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, urls.length) }, worker));
const broken = results.filter((r) => !r.ok);
if (values.json) {
console.log(JSON.stringify({ checked: results.length, broken: broken.length, results }, null, 2));
} else {
for (const r of results) {
const label = r.ok ? 'OK ' : 'BAD';
const detail = r.status ?? r.error;
const moved = r.finalUrl ? ` -> ${r.finalUrl}` : '';
console.log(`${label} ${String(detail).padEnd(22)} ${r.url}${moved}`);
}
console.log(`\n${results.length} checked, ${broken.length} broken`);
}
process.exitCode = broken.length > 0 ? 1 : 0;Step 3: how the important parts work
Reading arguments with parseArgs
util.parseArgs() is built into Node.js and handles --name=value options and positional arguments. Options are declared with a type and a default. Values arrive as strings, so the script converts them with Number.parseInt() and clamps them to sensible minimums (at least one worker and at least one second of timeout).
Extracting links
The regular expression matches https:// followed by any characters that are not whitespace, angle brackets, quotes, backticks, a closing parenthesis or a closing square bracket. That stops the match at the end of a Markdown link such as [text](https://example.com). A second step strips trailing punctuation such as full stops and commas. Wrapping the list in a Set removes duplicates, so a link that appears ten times is checked once.
This is deliberately simple. It will not handle URLs that legitimately contain parentheses (some Wikipedia links do). If your list has those, parse the Markdown properly with a Markdown parser rather than extending the regular expression.
Timeouts and errors
AbortSignal.timeout(timeoutMs) creates a signal that aborts the request after the given number of milliseconds. When that happens, fetch rejects with an error whose name is TimeoutError. For network failures, the underlying cause is attached as err.cause; its code is a readable value such as ENOTFOUND for a host that does not resolve. The script records that code instead of a long stack trace.
Redirects
With redirect: 'follow' (the default), fetch follows redirects and sets res.redirected to true. res.url then holds the final address, which the script reports next to the original so you can update your list.
The worker pool
Instead of a dependency such as p-limit, the script starts a fixed number of async workers. Each one takes the next index from a shared counter until the list is empty. Because JavaScript runs this code on a single thread, next++ cannot be interrupted between reading and incrementing, so no two workers check the same link. Results are written by index, so the output order matches the input order.
Step 4: run it
node check-links.mjs resources.md
echo "exit code: $?"When we ran it against the sample file on 7 October 2026, the output was:
OK 200 https://developer.mozilla.org/en-US/
OK 200 https://www.php.net/manual/en/
OK 200 https://nodejs.org/docs/latest/api/
BAD 404 https://nodejs.org/this-page-does-not-exist
OK 200 https://github.com/education
BAD ENOTFOUND https://no-such-host.invalid/
6 checked, 2 broken
exit code: 1Notice that http://github.com/ in the sample file was not checked: the pattern only matches https:// links. For machine-readable output, add --json. You can also lower the concurrency for a fragile server or raise the timeout for slow sites:
node check-links.mjs resources.md --json --concurrency=2 --timeout=15000 > report.json
Step 5: run it every week with GitHub Actions
Put the script in scripts/check-links.mjs and your list in docs/resources.md, then add .github/workflows/check-links.yml:
name: Check resource links
on:
schedule:
- cron: '17 3 * * 1' # Mondays at 03:17 UTC
workflow_dispatch:
permissions:
contents: read
jobs:
links:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- run: node scripts/check-links.mjs docs/resources.mdA few details from GitHub's documentation on the schedule event are worth knowing:
- Scheduled workflows only run on the default branch, and only if the workflow file exists there.
- Runs can be delayed during periods of high load, especially at the start of every hour, and queued jobs can be dropped. Choosing an odd minute (17 past the hour in the example) reduces that risk.
workflow_dispatchadds a "Run workflow" button so you can trigger a check by hand.
Because the script exits with code 1 when any link fails, the workflow run is marked as failed and GitHub can notify you.
Handling false positives
Some sites block automated requests or require JavaScript, and they will report 403 or 429 even when the page works in a browser. Do not respond by ignoring all 4xx statuses. Instead:
- Check the link by hand once. If it works in a browser, add it to an allowlist file and skip it in the script.
- Retry 429 responses later rather than immediately. A weekly schedule already spreads requests out.
- Keep concurrency low for lists with many links on the same domain.
- Treat timeouts as warnings if your list includes slow academic or government sites, and only fail the build on 404, 410 or DNS errors.
Next steps
You now have a dependency-free checker that you can run locally or on a schedule. Useful extensions include an allowlist of URLs to skip, a per-domain concurrency limit, retries with backoff for 429 and 503 responses, and writing a Markdown report that you can paste into an issue. If you maintain a public resource list, adding this check means readers are far less likely to land on a dead page.
Frequently asked questions
Do I need node-fetch or axios for this in Node.js 24?
No. The global
fetchhas been stable since Node.js v21.0.0, so Node.js 24 can make HTTP requests without extra packages.Why does the script try HEAD before GET?
HEAD fetches only headers, which is faster and lighter. Some servers reject HEAD with 405, 403 or 501, so the script retries those with GET.
Why might a working page show as broken?
Some sites block automated clients or rate-limit them with 403 or 429. Check such links by hand once and add them to an allowlist instead of ignoring all 4xx statuses.
Will a scheduled GitHub Actions workflow always run on time?
Not always. GitHub says scheduled runs can be delayed at times of high load, especially at the start of the hour, so pick an odd minute.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Node.js v24 Globals: fetch and AbortSignal — OpenJS Foundation
- Node.js v24 util.parseArgs — OpenJS Foundation
- Node.js Releases — OpenJS Foundation
- Events that trigger workflows: schedule — GitHub
- actions/setup-node README — GitHub
- actions/checkout releases — GitHub
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

Resource guideResources
Cron syntax cheat sheet: crontab, GitHub Actions, Kubernetes
Cron syntax with tested expressions, the day-of-week trap, and how time zones and limits differ in crontab, GitHub Actions, Kubernetes and Laravel in 2026.
By Cloudsoft Zone

Resource guideResources
Official documentation worth bookmarking for PHP, JavaScript and Node.js developers
A checked list of primary references for web developers: MDN, web.dev, the PHP manual and support schedule, Node.js API docs and releases, TypeScript, Laravel, React and WordPress docs, OWASP Cheat Sheets, DevDocs and endoflife.date.
By Cloudsoft Zone

ComparisonResources
Student developer programmes in 2026: GitHub Education, Azure, JetBrains, AWS and Google
What students actually get from GitHub Education, Azure for Students, JetBrains educational licences, AWS Educate and Google Cloud for students, who qualifies, how to verify without an academic email, and what to watch out for.
By Cloudsoft Zone
