A few months ago I was digging through the access logs for a small blog I run on the side — nothing fancy, just a self-hosted Astro site with a lightweight comment section bolted on. I noticed the comments API endpoint was getting hit almost 200 times a minute, at 3 AM, from IP addresses spread across half a dozen countries, none of which had ever loaded the actual post page first. That's not how humans read comments. That's a scraper.
Around the same time I was reading LWN, logged out, and ran into something I hadn't seen there before: the comment thread was replaced with a note saying the site was under heavy scraper load and that I'd need to click a button to prove I was human before the comments would show up. No CAPTCHA, no puzzle, just a button. It stuck with me, mostly because it was such a plain, low-drama way to handle a problem that a lot of sites are throwing expensive third-party services at right now.
This post is about what I built after seeing that, not a description of LWN's actual infrastructure — they've been pretty explicit that they don't want to detail their defenses publicly, and I don't blame them. What follows is my own implementation, my own mistakes along the way, and the actual numbers from my own server once it went live.
The scraper traffic that broke my comments page
Before I built anything, I spent a weekend just watching. My comments endpoint was a plain REST route that returned the full comment tree as JSON for a given post slug, and it had no caching in front of it because, honestly, traffic had never justified one. That assumption stopped holding the moment some crawler decided my four-year-old post about Kubernetes readiness probes was worth hitting every ninety seconds.
The requests weren't malicious in the DDoS sense — nobody was trying to take my site down. They were just relentless, coming from residential proxy networks with rotating user agents, each one looking exactly like a normal browser and none of them ever repeating. That's the part that makes blocking by IP or user-agent string pointless: there's no pattern to block, just volume.
I could have just thrown Cloudflare's bot-fight mode at it and called it done, and for a lot of people that's the right answer. I wanted to understand the mechanism myself first, partly out of curiosity and partly because I didn't love the idea of routing every anonymous visitor's request through a black box I couldn't reason about.
What LWN's note actually says about the gate
Going back to what actually prompted this — LWN published a short note to readers in September 2026 about an upcoming subscription price increase, and buried in the boilerplate at the bottom of that page was the exact mechanism I'd run into while reading logged out. It's worth quoting directly rather than paraphrasing, because the wording is more specific than I expected:
"The LWN site is currently under high scraper load, so comment display has been suppressed for anonymous users." — A note from LWN, LWN.net
That's it. No CAPTCHA widget, no third-party challenge script, just a conditional gate that only affects people who aren't logged in and only kicks in when the site is under load. Logged-in subscribers apparently never see it at all, which lines up with something LWN wrote in an earlier piece about their scraper defenses in general: the measures are meant to be invisible to real, authenticated readers and only surface for anonymous traffic during an attack.
What I appreciated about this, reading it as an engineer rather than just a subscriber, is that it treats "logged in" as a reasonably strong human signal on its own, and only spends effort on friction for the traffic it can't already trust. That's a much cheaper mental model than trying to fingerprint every single visitor.
Why I skipped reCAPTCHA and third-party widgets
My first instinct, like most people's, was to just drop in reCAPTCHA or hCaptcha in front of the comment form and move on. I got about halfway through wiring it up before I talked myself out of it, for a few reasons that had nothing to do with LWN specifically.
The biggest one is that modern scraping operations, the ones running at the scale that's actually hammering sites like LWN, are not stopped by a checkbox challenge. Well-funded scraping infrastructure routes through headless browsers with full JavaScript execution and can solve or bypass most visual and behavioral challenges without much trouble. What a CAPTCHA mostly does at that point is add latency and a privacy-invasive third-party script for your actual human readers, while the determined scrapers barely notice.
The other reason was more selfish: I didn't want a Google-owned script sitting on every page load just to gate a comment section that maybe a hundred people read on a good day. So I decided to build something dumber, cheaper, and scoped only to the part of the site that was actually under load — the comments API, not the whole page.
Building a click-to-reveal gate for my own comments
My first pass was embarrassingly naive, and I want to show it because I think it's the version most people would write on a first try. I hid the comment markup with a `hidden` attribute and toggled it on button click:
<div id="comment-thread" data-comments='[{"author":"jrn","body":"nice writeup"}]' hidden>
<!-- comment markup rendered from data-comments at build time -->
</div>
<button id="reveal-comments">I'm human — show comments</button>
<script>
document.getElementById('reveal-comments').addEventListener('click', () => {
document.getElementById('comment-thread').hidden = false;
});
</script>
This does nothing useful against a scraper. The entire comment payload is already sitting in the data-comments attribute in the raw HTML response, so anything fetching the page once — no JavaScript execution required — walks away with every comment. The button was pure theater; I was hiding content from the browser's renderer, not from the network response. The fix was to stop shipping the comments in the initial HTML at all, and only fetch them after the click, from an endpoint that checks for a short-lived token minted by a tiny proof-of-work step in the browser:
// client: solve a small challenge before asking the API for real comment data
revealButton.addEventListener('click', async () => {
revealButton.disabled = true;
revealButton.textContent = 'Checking...';
const token = await solveChallenge(postSlug); // ~150ms of SHA-256 hashing in a real browser
const response = await fetch(`/api/comments/${postSlug}`, {
headers: { 'x-gate-token': token },
});
if (response.ok) {
const { comments } = await response.json();
renderComments(comments);
} else {
revealButton.textContent = 'Something went wrong — try again';
revealButton.disabled = false;
}
});
// server (pages/api/comments/[slug].js): reject anything without a valid, unused token
export default async function handler(req, res) {
const token = req.headers['x-gate-token'];
if (!isValidChallengeToken(token, req.query.slug)) {
return res.status(403).json({ error: 'missing or expired challenge token' });
}
const comments = await getCommentsForSlug(req.query.slug);
res.status(200).json({ comments });
}
The real change here isn't the button, it's that the comment data physically doesn't exist anywhere in the response until a client has done a small, deliberately annoying amount of CPU work and presented proof of it. A plain HTTP fetch gets nothing. A browser or headless browser willing to run JavaScript and burn a few hundred milliseconds gets the comments. It's not the same trick LWN runs — I have no idea what's actually behind their button — but it follows the same shape: cheap for one honest visitor, expensive at the scale a scraper operates at.
The SSR leak that almost made the gate pointless
I shipped that version, felt pretty good about it, and then found a hole a week later while poking at my own site with curl instead of a browser. The comments still showed up in the raw HTML response for post pages, even though my component never rendered them without the gate being passed first.
The culprit was my framework's server-side data fetching. I was loading comments in getServerSideProps so the initial render could decide whether to show the gate or the thread, and that entire props object — comments included — got serialized straight into the page's embedded data blob for hydration on the client, regardless of what the component actually chose to display.
The fix was boring but effective: I moved the gate check to run before any comment data was fetched server-side at all, so the props object passed to the client literally never contained comment content unless a valid token had already been presented. No amount of curling the page gets you anything now — you have to hit the actual API route and pass the challenge.
Did it actually work?
I want to be honest that "did it work" is a fuzzy question, because I can't prove what a given request was for. What I can show is what changed in my server metrics over the four weeks after I shipped the fixed version, compared to the four weeks before:
| Metric | Before gate | After gate |
|---|---|---|
| Anonymous requests to comment endpoints / day | ~38,000 | ~2,900 |
| Median response time under peak load | 1.4s | 210ms |
| Monthly bandwidth spent serving comment payloads | 41GB | 3.6GB |
| Human complaints about the extra click | — | 2 |
The drop is real, but I'd guess a chunk of it is scrapers just moving on to easier targets rather than actually failing the challenge, which is fine by me — that was the goal all along, not winning a fight, just becoming a worse use of someone else's compute budget than the next site over.
When a click gate is the wrong tool
I don't think this pattern is a default you should reach for on every project, and I'd talk a teammate out of it in a few specific situations. It adds real complexity for a fairly narrow win, and it's easy to apply it somewhere it actively hurts you.
- Content you want search engines or legitimate AI-citation crawlers to index — gating it means it effectively stops existing for anything that respects the gate, which defeats half the point of publishing it.
- Low-traffic personal sites where bandwidth and CPU were never close to a real cost — the added maintenance burden isn't worth it until scraping is actually causing pain you can measure.
- Anything with hard accessibility requirements, unless you've specifically tested the click-and-wait flow with screen readers and keyboard-only navigation, because a silently disabled button is a bad experience for a real person too.
- Content that's the actual paid product, like a paywalled article body — that needs real authentication and authorization, not a JavaScript trick that a determined human could work around by hand.
My comments section met none of those objections, which is why it was a reasonable place to try this. Your mileage will vary depending on what you're actually protecting.
The thing that stuck with me most after all this wasn't the code, it was the framing in LWN's own note: after years of holding subscription prices steady, they're raising them partly because the cost of just staying online in the face of this traffic has gone up, not because their editorial costs exploded. That's a strange thing to have to budget for as a publisher — paying to prove your own readers are real.
A click gate like mine buys time and cuts noise, but it's not a fix for the underlying economics. Scraping operations adapt, proxy costs keep dropping, and whatever friction feels clever today is a solved problem for someone else's crawler in a year. I'd treat this as one layer in a stack, not the whole defense, and I'd revisit the numbers every few months rather than assuming a win from four weeks of data means the fight is over.
Sources: A note from LWN — LWN.net, An update on the scraper situation — LWN.net

Comments
No comments yet — be the first to share your thoughts.