Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
ResourcesDevOpsJavaScriptNode.jsTutorial

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.

Cloudsoft Zone

Updated 9 min read

Hands typing on a laptop keyboard while the screen shows lines of code in a text editor
Photograph: Crew crew / CC0, via Wikimedia Commons
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.

Dense bundle of blue, yellow and green Ethernet cables plugged into a network switch
Network cables in a switch. Every link check is a real request, so keep concurrency low and identify your client.

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 -v to 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:

DecisionReason
Try HEAD first, then fall back to GETHEAD 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 themA redirect is not broken, but it tells you the bookmark should be updated before the old address disappears.
A timeout per requestA server that never answers should not stall the whole run. AbortSignal.timeout() cancels the request after a set time.
Limited concurrencyChecking 200 links at once can trigger rate limits. A small pool of workers (5 by default) is polite and still fast.
Cancel the response bodyWe only need the status. Cancelling the body releases the connection instead of leaving it open until it is garbage-collected.
Identify yourselfA descriptive User-Agent with a contact URL helps site owners understand the traffic.
Only check https:// linksPlain 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):

Markdown
# 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.

JavaScript
// 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).

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

Shell
node check-links.mjs resources.md
echo "exit code: $?"

When we ran it against the sample file on 7 October 2026, the output was:

Text
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: 1

Notice 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:

Shell
node check-links.mjs resources.md --json --concurrency=2 --timeout=15000 > report.json
Close view of rack-mounted servers with rows of drive bays and blue and green status lights
Rack-mounted servers at NOIRLab headquarters. The pages your list links to live on servers that move, change and sometimes disappear.

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:

YAML
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.md

A 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_dispatch adds 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:

  1. Check the link by hand once. If it works in a browser, add it to an allowlist file and skip it in the script.
  2. Retry 429 responses later rather than immediately. A weekly schedule already spreads requests out.
  3. Keep concurrency low for lists with many links on the same domain.
  4. 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 fetch has 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.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Node.js v24 Globals: fetch and AbortSignal — OpenJS Foundation
  2. Node.js v24 util.parseArgs — OpenJS Foundation
  3. Node.js Releases — OpenJS Foundation
  4. Events that trigger workflows: schedule — GitHub
  5. actions/setup-node README — GitHub
  6. 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.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.