What rel=nofollow Actually Does — Measured in a Real Browser
The usual explanation is about PageRank, which is unobservable. The part that
is observable is the HTTP request, and almost every statement about nofollow conflates the
two. So I served a page with five links differing only in their rel attribute,
clicked each in Chrome, and recorded the Referer the destination received.
The claim under test
Common statements about rel="nofollow", in descending order of confidence:
- It tells search engines the link is not an endorsement. This is what the spec says.
- It does not stop the click from working, or stop the visit from being counted by the destination. Testable.
- It does not change what the browser sends in the request. Testable, and I had never seen it tested.
- It prevents the link from passing PageRank. Not observable from outside Google.
Point 4 is the one everybody argues about and nobody can measure. Points 2 and 3 are measurable in a few minutes, and they determine whether a nofollow link is worth placing for traffic reasons — a separate question from its ranking effect.
Experiment 1 — five rel variants, same target
Two http.server instances on 127.0.0.1:8765 and
:8766. Because the port differs, the browser treats them as different origins,
which is what exercises the cross-origin referrer rules. The page on 8765 contains ten links:
five rel variants pointing at 8765, and the same five pointing at 8766. Every link
is otherwise byte-identical.
LINKS = [
("plain", ""),
("nofollow", ' rel="nofollow"'),
("nofollow_ugc", ' rel="nofollow ugc"'),
("noreferrer", ' rel="noreferrer"'),
("noopener", ' rel="noopener"'),
]
Each link was clicked programmatically through the DevTools Protocol, and the destination
logged self.headers.get("Referer"). Results, for a click from
http://127.0.0.1:8765/ to http://127.0.0.1:8766/target:
| rel on the link | Referer received by destination | Header present |
|---|---|---|
| (none) | http://127.0.0.1:8765/ | yes |
nofollow | http://127.0.0.1:8765/ | yes |
nofollow ugc | http://127.0.0.1:8765/ | yes |
noreferrer | absent | no |
noopener | http://127.0.0.1:8765/ | yes |
Same-origin results were identical except that the full path was included
(/target?k=plain etc.), which is the expected path-level difference between
same-origin and cross-origin defaults.
The finding is unambiguous: rel="nofollow" does not alter the request in
any way. The destination receives exactly the same Referer, and the same
Sec-Fetch-Site: same-site, as a plain link. If a site owner reads their referrer
log, a nofollow visit is indistinguishable from a followed one. Only noreferrer
strips it — and noreferrer also implies noopener, which is a
different feature entirely.
So "nofollow links still pass traffic" is correct, and for a duller reason than
people assume. The browser never knew or cared about the rel value when
building the request. The click is a navigation like any other. The rel attribute
is metadata consumed after the fact, by crawlers, not a transport modifier.
Experiment 2 — what actually removes the referrer
If rel does not govern this, Referrer-Policy does. Same setup,
except the source page is served with a different Referrer-Policy response header
per run, on 8775 → 8776:
| Referrer-Policy on source | Referer received cross-origin |
|---|---|
no-referrer | absent |
origin | http://127.0.0.1:8775/ |
origin-when-cross-origin | http://127.0.0.1:8775/ |
strict-origin-when-cross-origin | http://127.0.0.1:8775/ |
unsafe-url | http://127.0.0.1:8775/?pol=unsafe-url |
This is the matrix that matters operationally. A site that wants referrer credit for
outbound links gets it under the browser default
(strict-origin-when-cross-origin, which strips the path but keeps the host), and
loses it entirely under no-referrer. Note the unsafe-url row: the path
survives cross-origin only under that policy, which is why full referrer paths are so rare in
analytics.
Checking the two domains in this cluster directly with curl -I:
| Host | Referrer-Policy sent | Effect on outbound clicks |
|---|---|---|
celticgemini001.github.io | not set (browser default) | host sent, path stripped |
upgolab.com | no-referrer |
nothing sent at all |
That second row is worth sitting with. A site that sends
Referrer-Policy: no-referrer is invisible in the referrer logs of everything it
links to. If you are judging a link-building effort by destination-side referrer reports, links
from such a site will look like direct traffic. The visits are real; the attribution is gone.
Any measurement of off-site linking that relies on destination referrers is therefore blind to
every source site configured this way — which is a meaningful and growing share of the web.
What this changes about placing links
- Judge nofollow links by traffic, not by "link juice". The traffic is fully real, and it is measurable at the destination. The ranking effect is not measurable by you, which means it should not drive the decision.
- If you need attribution, you need a parameter. Because the browser may
strip the referrer and the source site may strip it too, a
?utm_source=-style parameter is the only attribution that survives every policy in the matrix above. - A nofollow link on a page with
no-referreris doubly invisible to a referrer-based audit, and identical in effect to a followed link on a page without it. The two attributes are orthogonal, and conflating them is the source of most of the bad advice on this topic.
Reproducing it
# two origins: different port = different origin for referrer purposes
python3 - <<'PY'
import http.server, threading, json
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
print(json.dumps({"port": self.server.server_address[1],
"path": self.path,
"referer": self.headers.get("Referer")}))
body = b'<a id=t href="http://127.0.0.1:8766/target" rel=nofollow>go</a>'
self.send_response(200); self.send_header("Content-Length", str(len(body)))
self.end_headers(); self.wfile.write(body)
for p in (8765, 8766):
threading.Thread(target=http.server.ThreadingHTTPServer(("127.0.0.1", p), H)
.serve_forever, daemon=True).start()
threading.Event().wait()
PY
# then click it in a real browser and read the referer the second server printed
The measurement is four lines of server and one click. It is worth running once, because the
result is counter-intuitive in a way that reading the spec does not fix: the rel
attribute simply is not consulted when the request is built.
The tool these notes come from
This cluster of notes exists because claims about images and about links are both routinely asserted without measurement. If what you actually need is to upscale a photo or a short video to 2K or 4K in the browser, that is the tool the image measurements were taken against — and the same habit applies: run it on your own file, then compare, rather than trusting the preset name.