Most broken links aren't caused by a page being deleted. They're caused by a small mistake in one part of the URL: a relative path resolved from the wrong place, a space that wasn't encoded, a query string that got mangled by an email client.
If you understand the parts of a URL, you can usually look at a broken link and say which part broke in a few seconds. Here's the tour.
https://user@shop.example.com:8443/products/blue%20chair?color=navy&size=l#reviews \___/ \__/ \______________/ \__/\__________________/ \________________/ \_____/ scheme user host port path query fragment You can take any URL apart with the built-in URL class in JavaScript (in browsers and Node):
const u = new URL("https://shop.example.com:8443/products/blue%20chair?color=navy&size=l#reviews"); console.log(u.protocol); // "https:" console.log(u.hostname); // "shop.example.com" console.log(u.port); // "8443" console.log(u.pathname); // "/products/blue%20chair" console.log(u.searchParams.get("color")); // "navy" console.log(u.hash); // "#reviews" Let's go part by part.
Scheme: https:
Tells the browser how to fetch: https, http, mailto, tel, ftp.
How it breaks links:
- Missing scheme.
href="www.example.com"isn't a link to example.com. It's a relative link to a path calledwww.example.comon your own site. This is one of the most common broken links in CMS content. -
httpthat doesn't redirect. If the target site dropped plain HTTP,http://links fail even though the page exists.
// what a missing scheme actually resolves to new URL("www.example.com", "https://myblog.dev/posts/hello/").href // → "https://myblog.dev/posts/hello/www.example.com" (broken!) Host: shop.example.com
The domain name (or IP address) of the server.
How it breaks links:
- Domain expired or changed. The whole site is gone, or moved to a new domain without redirects. Every link to it breaks at once.
- Subdomain removed.
blog.example.comgot folded intoexample.com/blogand nobody set up redirects. - Typos.
exmaple.com. Some of those typo domains are even registered by someone else.
Port: :8443
Usually invisible (443 for HTTPS, 80 for HTTP). When you see an explicit port in a public link, be suspicious.
How it breaks links: URLs copied from a staging or development environment, like http://localhost:3000/pricing or https://example.com:8443/admin, get pasted into content and published. They work for whoever wrote them and nobody else.
Path: /products/blue%20chair
The location of the resource on the server. This is where most link rot happens.
How it breaks links:
- Pages moved or renamed without redirects (the classic).
- Case sensitivity. On many servers,
/Products/and/products/are different paths. A link that worked on a case-insensitive development machine can 404 in production. - Relative paths resolved from the wrong place. This is the subtle one:
const base = "https://example.com/docs/guide/"; // note the trailing slash new URL("setup", base).href; // "https://example.com/docs/guide/setup" new URL("../setup", base).href; // "https://example.com/docs/setup" new URL("/setup", base).href; // "https://example.com/setup" const base2 = "https://example.com/docs/guide"; // no trailing slash new URL("setup", base2).href; // "https://example.com/docs/setup" (different!) The same relative link resolves to different URLs depending on whether the current page ends with a slash. Move a page from /guide/ to /guide, and every relative link on it can quietly shift.
Encoding: %20 and friends
URLs can only contain a limited set of characters. Spaces, many symbols and non-ASCII characters must be percent-encoded: a space becomes %20, é becomes %C3%A9.
How it breaks links:
- Unencoded spaces in file names (
/files/Annual Report.pdf). Some tools encode them, some cut the link off at the space. - Double encoding.
%20gets encoded again to%2520, which points somewhere that doesn't exist.
encodeURI("https://example.com/files/Annual Report.pdf"); // → "https://example.com/files/Annual%20Report.pdf" encodeURI("https://example.com/files/Annual%20Report.pdf"); // → "https://example.com/files/Annual%2520Report.pdf" (double-encoded: broken) The fix is boring but effective: avoid spaces and special characters in file names and slugs entirely.
Query: ?color=navy&size=l
Parameters for the server: search terms, filters, tracking tags.
How it breaks links:
- The server ignores or rejects parameters it doesn't expect after a redesign. A link to
?id=123stops working when the new site uses/item/123. -
&turned into&and back. In HTML source,&should be written as&. Copying between HTML, Markdown and plain text sometimes leaves a literal&in the URL, so the server sees a parameter calledamp;size. - Tracking parameters making "different" URLs for the same page. That's not broken, but it confuses analytics and deduplication.
Fragment: #reviews
Points to a spot within the page. Here's the key fact: the fragment is never sent to the server. The browser handles it after the page loads.
How it breaks links: if the target heading's id changes, the page still loads with a 200, but the browser lands at the top instead of the section. No status code reports it. A link checker has to load the page and look for the id to catch it.
Diagnosing a broken link, part by part
When a link fails, walk it left to right:
- Scheme: present?
https? - Host: does the domain still resolve? (
dig +short example.com) - Port: any explicit port that looks like dev or staging?
- Path: right case? Moved? Resolved relative to the right base?
- Encoding: spaces,
%25, odd characters? - Query:
&, or parameters the new site ignores? - Fragment: does the anchor
idstill exist?
Most broken links give themselves away by step 4.
Automated checkers do the same walk at scale. A simple script with new URL() catches missing schemes and localhost links, and a crawler such as linkbruno.com checks the rest against live responses.
Related reading
- Should your docs link to GitHub's main branch or to a permalink? Both options rot.
- HEAD vs GET for link checking: HEAD is faster, and it lies more than you'd think
- Anchor link not working? How to find and fix broken #section links
- How to check a website for broken links for free (5 methods that actually work)
Which part of the URL has caused your most annoying broken link? My vote is the trailing-slash relative-path trap. Tell me yours in the comments.