You know that feeling when you click a link in a project’s README and it 404s right in your face? Me too. Broken links are the quiet rot of the web — they creep into documentation, blog posts, and large codebases without anyone noticing, and they make otherwise solid content look abandoned. The fix is a tool that checks links automatically, and the best one I’ve used is lychee.
lychee is a fast, async, stream-based link checker written in Rust. It scans Markdown, HTML, and reStructuredText files (plus whole websites), finds broken hyperlinks and mail addresses, and spits out a clear report — or lets you integrate it straight into CI so broken links never ship in the first place. It runs as a command-line tool, a reusable library, and a GitHub Action.
In this guide I’ll walk you through what makes lychee worth using, how to install and configure it, how to wire it into your CI so it runs automatically, and where it fits compared to the other link checkers out there. By the end you’ll have a setup that catches dead links before your readers ever do.
Why Bother Checking Links at All?
Let me make the pitch quickly, because link checking sounds like a chore until you see what it saves you:
- Reader trust. A single broken link in a tutorial or guide makes readers wonder if the rest is stale too. Link rot is a signal of neglect, even when it isn’t.
- SEO and search. Broken links hurt crawlability and user experience signals. Search engines follow your links, and dead destinations waste crawl budget while sending soft quality signals.
- Docs and READMEs are bigger than ever. Every project ships documentation these days, and docs-as-code means repos full of Markdown that link to tools, versions, and APIs that change constantly.
- AI citations. With search engines and AI assistants now pulling facts from your pages, a dead link your article points to reflects badly on you every time someone follows it.
- It never happens „just once.“ Sites go down, domains expire, and projects archive — links rot in bulk. Manual checking is a losing battle.
The bottom line: link checking is cheap insurance on content you care about. Do it once automatically, and you never have to babysit a link inventory by hand again.
What Is lychee?
lychee started life as a demo in episode 10 of the Hello Rust podcast and grew into one of the most popular open-source link checkers around, with the latest stable release at v0.24.2. It’s dual-licensed under Apache-2.0 and MIT, so you can use it just about anywhere without license friction.
A nice detail for European readers: lychee is funded in part through the NGI0 Core Fund from NLnet, backed by the European Commission’s Next Generation Internet programme. It’s genuinely open-source infrastructure, not a freemium preview of a paid product.
What lychee checks and how it works
Under the hood, lychee reads files and URLs streamed and concurrently, which is where the speed comes from. Instead of loading everything into memory and checking links one at a time, it processes links in parallel with a configurable number of concurrent checks. On a large documentation site that’s the difference between waiting minutes and waiting seconds.
Out of the box lychee can:
- Check Markdown, HTML, reStructuredText, plain text, and more
- Crawl and check a live website (including its sitemap)
- Validate mailto: addresses
- Verify anchor fragments if you enable them
- Output to text, JSON, Markdown and more for CI pipelines
- Run as a single static binary — no runtime dependencies to install
- Cache results between runs so repeated checks aren’t wasted
It’s used in production by big projects you’ve definitely heard of — including OpenSearch, HashiCorp’s Consul, Nuxt, Fastify, containerd, and Sindre Sorhus’s execa. It even checks its own repository with itself.
Installing lychee
lychee ships as a single static binary, which makes installation ridiculously easy on every platform. Here are the options, sorted by how I’d actually use them:
macOS via Homebrew
If you’re on a Mac, Homebrew is the fastest route:
|
1 2 3 |
brew install lychee |
Linux package managers
On Arch-based systems it’s in the official repos. Ubuntu and friends get it via Snap, Alpine via apk:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
# Arch Linux pacman -S lychee # Ubuntu / Snap snap install lychee # Alpine Linux (Edge/testing repos) apk add lychee # openSUSE zypper in lychee |
Docker
Prefer containers (like I do in most of my self-hosted setups)? The official image is on Docker Hub:
|
1 2 3 4 5 6 |
docker pull lycheeverse/lychee # Check a local README: docker run -it -v "$PWD":/work lycheeverse/lychee /work/README.md |
There are also -alpine variants of every tag if you want a slimmer image, plus nightly builds if you’re feeling adventurous.
Cargo
Rust users can grab it from crates.io. This compiles from source, so it takes a few minutes, but you get the tool integrated with your Rust toolchain:
|
1 2 3 |
cargo install lychee |
Prebuilt binaries
No package manager and no Rust installed? Head over to the GitHub releases page, download the archive for your platform, unpack it, and drop the lychee binary anywhere on your PATH. That’s it — the static binary approach means no libraries to chase.
Whichever route you pick, verify it works:
|
1 2 3 |
lychee --version |
Basic Usage: Checking Links in Seconds
The most basic invocation takes one or more inputs — files, directories, or URLs — and checks everything it finds. The simplest possible start is checking a single Markdown file:
|
1 2 3 |
lychee README.md |
Point it at a whole directory and it’ll discover every supported file inside and check them all:
|
1 2 3 |
lychee docs/ |
Against a live website, it crawls pages and checks every link it finds — sitemaps included:
|
1 2 3 |
lychee https://example.com |
You can even pipe content in via stdin, which is handy in shell pipelines:
|
1 2 3 |
echo "Check out https://portalzine.de" | lychee - |
Reading the output
Default output uses an emoji-ish status per link, but since we don’t do emoticons around here, the more useful view is the detailed format with status codes:
|
1 2 3 |
lychee --verbose README.md |
Each line shows the URL and its result — a 200 means everything’s fine, 404 means the page is gone, and statuses like 429 (rate limited) mean the server couldn’t answer properly even though the link technically works.
Exit codes: the part CI cares about
For automation the important bit is what lychee returns when it finishes. It uses three exit codes:
Exit code | Meaning | Use it for |
|---|---|---|
0 | All links valid, nothing broken | Let the pipeline pass |
1 | At least one link is broken | Fail the build so nobody merges dead links |
2 | A runtime error occurred (e.g. a file didn’t exist, network problem) | Investigate infrastructure, not links |
That clean exit-code contract is the backbone of the whole CI story — it turns „link rot“ into a failing pipeline step, which means a reviewer’s job becomes trivial: fix the link or justify the exclusion.
JSON output for pipelines
For scripted processing, switch to structured output:
|
1 2 3 4 |
lychee --format json docs/ > results.json lychee --format markdown docs/ > report.md |
JSON output makes it trivial to build your own dashboards, Slack hooks, or Grafana panels on top of the check results.
Configuration: Taming lychee With a Config File
Running lychee with bare defaults works fine, but real projects need a config file. lychee looks for a lychee.toml in the current directory by default (you can point it anywhere with --config), and every option you can pass on the command line has a TOML equivalent. This is where the tool really shines — once your config lives in the repo, everyone gets the same sane setup for free.
Here’s a practical example covering the options I actually reach for, based on the official lychee.example.toml reference:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
# ---- Display ---- verbose = "info" # error, warn, info, debug, trace format = "detailed" # or: compact, json, markdown no_progress = true # don't show the progress bar in CI # ---- Cache ---- cache = true # reuse results between runs max_cache_age = "2d" # discard cached requests older than 2 days cache_exclude_status = "500.." # ---- Runtime ---- max_concurrency = 14 # parallel link checks max_redirects = 10 max_retries = 2 # retries before a link is declared dead retry_wait_time = 2 # seconds between retries timeout = 20 # seconds from connect to response # ---- Requests ---- accept = ["200", "429"] # treat 429 (rate-limited) as valid require_https = false # turn on to flag http:// links as errors include_fragments = "full" user_agent = "curl/7.83.1" # ---- Exclusions ---- # Regular expressions for URLs to skip entirely exclude = [ '^https?://www\.linkedin\.com', '^https://example\.com', ] # Paths (directories / extensions) to skip when collecting inputs exclude_path = ["vendor", "node_modules"] # ---- Hosts (per-host rate limiting) ---- [hosts."gitlab.torproject.org"] headers = { "User-Agent" = "curl/8.7.1" } [hosts."blog.example.com"] concurrency = 2 request_interval = "50ms" |
Priority recap: HIGH — set exclude and exclude_path first, because they stop your pipeline from failing on links you can’t control (like LinkedIn or a flaky third-party site). MEDIUM — turn on cache if you’re running checks frequently, per-host rate limiting if you’ve ever annoyed a server. LOW — require_https if you’re security-hardening for mixed content.
The exclude list deserves special attention. Regular expressions let you surgically whitelist problematic-but-unavoidable links without disabling checks globally. One expression per entry, and patterns like ^https?://(www\.)?linkedin\.com handle optional subdomains and HTTP/HTTPS at once.
For quick, one-off overrides you can skip the config file entirely and pass flags directly:
|
1 2 3 |
lychee --exclude '^https?://www\.linkedin\.com' --max-concurrency 20 README.md |
You can also keep a simple .lycheeignore file in the repo root — one regex per line — for exclusions that don’t belong in the main config. This is a nice middle ground when you want contributors to add ignores without touching the TOML.
Running lychee in CI: GitHub Action and Cron
Local checking is great, but the real win is automation. lychee has an official GitHub Action, so adding it to your workflow is a five-minute job. Here’s the recommended setup that checks your whole repo once a day and opens an issue when it finds broken links:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
name: Links on: push: pull_request: schedule: - cron: "0 18 * * *" # every day at 18:00 UTC jobs: linkChecker: runs-on: ubuntu-latest permissions: issues: write # needed for create-issue-from-file steps: - uses: actions/checkout@v4 - name: Run lychee uses: lycheeverse/lychee-action@v2 with: args: "--verbose --no-progress ." - name: Create issue on broken links uses: peter-evans/create-issue-from-file@v5 with: title: Broken links found content-filepath: ./lychee/out.md |
Two big quality-of-life additions. First, caching: tell lychee to cache results keyed to the commit, then restore that cache between runs. That keeps daily checks fast and avoids re-checking stable links:
|
1 2 3 4 |
# In the lychee step, add: args: '--root-dir "$(pwd)" --cache --max-cache-age 1d .' |
Second, pin your action. GitHub Actions move fast, so pinning to a specific tag (or better, a commit SHA) prevents a surprise update from breaking your CI. Add Dependabot with the github-actions ecosystem and it’ll keep you pinned-but-current automatically.
A note on scheduled checks and false positives
Running checks on a schedule (like the cron above) is the best way to catch link rot in real time. But external sites go down for reasons that have nothing to do with you, and flaky sites will occasionally trip up a perfectly good URL. That’s exactly what the exclude list and the accept status codes are for — treat rate limiting (429) and temporary states sensibly so your pipeline only fails on genuinely dead links.
You can also hit the Wayback Machine via the archive = "wayback" option to suggest archived versions of broken links — handy when a site you depend on disappears entirely.
lychee vs. the Alternatives
lychee isn’t the only game in town, and the „right“ tool depends on your input format and workflow. Here’s how it stacks up against the other open-source link checkers I looked at, all verified:
Tool | Language | Specialty | License | Best for |
|---|---|---|---|---|
lychee | Rust | Markdown, HTML, RST, whole sites | MIT / Apache-2.0 | Docs, READMEs, CI, speed |
Go | Recursive website crawl | MIT | Large live sites, massive speed | |
Go | Static generated HTML | MIT | Jekyll/Hugo/CMS output dirs | |
Node | Markdown files | ISC | Already using Node tooling | |
Node | HTML files and sites | MIT | Node projects, rich options | |
Python | Recursive site checking, GUI | GPL-2.0 | Python shops, feature-heavy |
Some honest guidance, since you deserve nuance, not marketing:
- lychee is the best all-rounder for documentation-driven repos. It handles Markdown, HTML, and RST natively, gives you that full format, checks mailto, and has the cleanest CI story with the official Action.
- muffet is arguably faster on giant live sites, and it’s a superb „crawl this whole domain“ tool in Go. If you only ever check running websites (not files), it’s a serious contender.
- htmltest is purpose-built for static site generators — point it at your Jekyll or Hugo output and it verifies alt text, favicons, hashes, and more, not just links.
- markdown-link-check is the lightweight Node option if you’re already neck-deep in a JavaScript toolchain and just want README coverage.
- broken-link-checker is the most configurable Node option, great for HTML and sites with granular controls.
- linkchecker has been around forever and brings a web interface and tons of output formats, at the cost of being heavier.
For my money, lychee wins for the typical portalZINE-style workflow — self-hosted docs, Markdown repositories, CI integration — because it does files and sites well with minimal setup and a single binary.
Which Setup Is Right for You?
Quick decision guide, take what applies:
- README-only, single project:
lychee README.mdin a pre-commit hook or one-line CI step. Good enough in minutes. - Docs site generated from Markdown (my default): config file with
exclude/exclude_path, cache enabled, and the GitHub Action on push + nightly cron. - Live site with no static files: look hard at muffet, or use
lychee https://your-site.comif you want one tool for everything. - Jekyll/Hugo static output: htmltest adds alt-text and favicon checks on top of links.
- Node-only monorepo: markdown-link-check integrates with your existing package scripts.
- You value the Wayback Machine fallback and JSON everywhere: lychee’s
archive = "wayback"and structured output are tough to beat.
My recommendation, the bottom line: for anyone with a Markdown documentation setup who wants broken links caught automatically before readers hit them, lychee is the tool I’d install first. Add it to CI, set the exclusions once, and forget about link rot for good.
Glossary
- Async / stream-based
- lychee processes links concurrently as it reads, rather than loading everything and checking one by one. This is the main reason it’s so fast on large codebases.
- Link rot
- The slow decay of hyperlinks as sites move, expire, or get archived. It’s what lychee is designed to catch.
- Static binary
- A compiled program with no runtime dependencies, so you can drop it on any machine and run it directly. lychee distributes these for easy install and updates.
- HEAD request
- An HTTP request that asks only for the response headers, not the body — a cheap way to check whether a URL exists. lychee falls back to GET when servers reject HEAD.
- Exit code
- A number a command returns on exit. lychee uses 0 (all good), 1 (broken links found), and 2 (runtime error), which CI systems read to pass or fail a step.
- Sitemap
- A file that lists the pages of a site so crawlers can find them. lychee can check links from sitemaps when crawling a live site.
- Wayback Machine archive
- A digital archive of web pages. lychee’s
archive = "wayback"option can look up archived versions of broken links. - Rate limiting (429)
- A server responding
429 Too Many Requestsbecause you hit it too fast. lychee has per-host concurrency and request-interval settings to avoid tripping it.
FAQ
What is lychee?
lychee is a fast, async, stream-based link checker written in Rust. It finds broken hyperlinks and mail addresses inside Markdown, HTML, reStructuredText files, and whole websites. It runs as a CLI, a library, and a GitHub Action, and is published on GitHub by lycheeverse.
Is lychee free and open source?
Yes. lychee is open source and dual-licensed under Apache-2.0 and MIT, so you can use it freely, including commercially. It’s also funded in part by the EU-backed NGI0 Core Fund via NLnet.
How do I install lychee?
On macOS run brew install lychee. On Arch use pacman -S lychee, Ubuntu snap install lychee, Alpine apk add lychee. There’s also a Docker image (docker pull lycheeverse/lychee), a Cargo install, and prebuilt binaries on the GitHub releases page.
What file formats does lychee support?
lychee natively checks Markdown, HTML, reStructuredText, and plain text files, plus mailto addresses. It can also crawl a live website, read from stdin, and use a sitemap.
How do I exclude links from lychee checks?
Use the exclude option in a lychee.toml config file with regular expressions to skip specific URLs, exclude_path to skip directories or file types, or a .lycheeignore file with one regex per line.
How do I run lychee in CI?
Use the official lycheeverse/lychee-action@v2 GitHub Action in a workflow, optionally on a cron schedule, and have it open an issue when it finds broken links. lychee also outputs JSON for custom pipelines.
What exit codes does lychee return?
lychee returns 0 when all links are valid, 1 when at least one link is broken, and 2 for a runtime error. CI systems use these to pass or fail a step automatically.
How fast is lychee compared to other link checkers?
lychee is built in Rust and checks links concurrently, making it extremely fast on large documentation sites and repos. Its single-binary convenience across Markdown, HTML, and sites, plus the official GitHub Action, make it a strong all-rounder.
Can lychee check a live website?
Yes. Running lychee https://example.com crawls the site and checks every link it finds, including sitemaps. For very large sites, muffet can be even faster, but lychee handles sites well and checks files too.
How do I check mailto addresses with lychee?
Mail address checking is on by default via include_mail = true. lychee validates mailto: links found in your files, so you’ll catch typos and dead addresses in documentation.
What is the difference between lychee and muffet?
lychee (Rust) checks both files and live sites with a clean Markdown/HTML focus and an official CI Action. muffet (Go) is a pure recursive website crawler that’s extremely fast on large running sites. Choose muffet for site-only crawls, lychee for docs and repos.
How do I make repeated lychee runs faster?
Turn on caching with cache = true in your lychee.toml and set max_cache_age. lychee stores results and only re-checks links that changed or expired. In CI you can restore the cache between runs to speed up daily checks.
