How to check 410 vs 404 response
Read the status line and the body together. curl -sS -i http://host/path prints 404 Not Found when the server cannot find the resource and 410 Gone when the resource was deliberately removed. The status carries the whole difference, which is permanence. The body decides whether a visitor who lands there can do anything next.
Why check this
Run this after a catalogue purge, after a migration that drops URLs, and on release sign-off for any change whose ticket says "delete".
The failure it prevents is a removal correct on the wire and useless on the screen. A route answers 410, the framework sends the status with no body, and the visitor gets a blank page. The status check passes: the status is what was asked for.
The second failure runs the other way. A URL taken down for two weeks answers 410 while it is away, and that status states the address will never work again. Both codes mean the resource is not here. Only 410 calls the absence permanent, so the check covers whether that claim is true.
Prerequisites
- curl 7.0 or later. No HTTP/2 build needed.
- Node 18 or later. The reader script uses
fetch, which Node 18 ships. - MDN on 404 and MDN on 410 for the wording each status commits you to.
- A free port.
netstat -ano | grep 9137prints nothing when 9137 is free.
Save this as gone-server.js. It answers a catalogue four ways, so both statuses and both body shapes sit on one host.
// node gone-server.js listens on http://127.0.0.1:9137
const http = require('http');
const page = (title, text) => `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>${title}</title></head>
<body><h1>${title}</h1><p>${text}</p><p><a href="/catalogue/">Catalogue</a></p>
</body></html>
`;
const routes = {
'/product/42': [200, page('Widget 42', 'In stock.')],
'/product/17': [404, page('Not found', 'No product has this id. It may never have existed.')],
'/product/9': [410, page('Gone', 'Widget 9 was discontinued on 2026-08-01 and will not return.')],
'/product/8': [410, ''] // 410 emitted the way a framework emits it when nobody wrote a body
};
http.createServer((req, res) => {
const [status, body] = routes[req.url] || [404, page('Not found', 'No route.')];
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8' });
res.end(body);
}).listen(9137, '127.0.0.1', () => console.log('listening on 127.0.0.1:9137'));
Save this as removal-check.mjs for step 6.
// node removal-check.mjs <url...>
// Prints the removal signal and whether the body is usable by a person.
for (const url of process.argv.slice(2)) {
const r = await fetch(url, { redirect: 'manual' });
const body = await r.text();
const signal = r.status === 410 ? 'PERMANENT' : r.status === 404 ? 'UNKNOWN ' : 'NOT A REMOVAL';
const usable = body.length > 0 && /<a\s[^>]*href=/i.test(body);
console.log(`${r.status} ${signal} ${String(body.length).padStart(4)} bytes body-usable=${usable} ${new URL(url).pathname}`);
}
Step 4 needs a second server. Save this as express-410.js and install Express beside it.
// npm install express && node express-410.js listens on http://127.0.0.1:9138
const express = require('express');
const app = express();
app.get('/product/42', (req, res) => res.type('html').send('<h1>Widget 42</h1>'));
app.get('/product/9', (req, res) => res.sendStatus(410)); // the one line that makes a 410
app.listen(9138, '127.0.0.1', () => console.log('express ' +
require('express/package.json').version + ' on 127.0.0.1:9138'));
Start both and read the Windows PIDs out of netstat, which step 7 needs.
node gone-server.js & node express-410.js & netstat -ano | grep LISTENING | grep 913
Steps
- Step 1.
Read the whole 404 response, headers and body.
curl -sS -i http://127.0.0.1:9137/product/17HTTP/1.1 404 Not Found content-type: text/html; charset=utf-8 Date: Fri, 11 Sep 2026 20:39:57 GMT Connection: keep-alive Keep-Alive: timeout=5 Transfer-Encoding: chunked <!doctype html> <html lang="en"><head><meta charset="utf-8"><title>Not found</title></head> <body><h1>Not found</h1><p>No product has this id. It may never have existed.</p><p><a href="/catalogue/">Catalogue</a></p> </body></html>The wording is the part to read: it reports absence and commits to nothing about the future.
- Step 2.
Read the 410 the same way.
curl -sS -i http://127.0.0.1:9137/product/9HTTP/1.1 410 Gone content-type: text/html; charset=utf-8 Date: Fri, 11 Sep 2026 20:39:57 GMT Connection: keep-alive Keep-Alive: timeout=5 Transfer-Encoding: chunked <!doctype html> <html lang="en"><head><meta charset="utf-8"><title>Gone</title></head> <body><h1>Gone</h1><p>Widget 9 was discontinued on 2026-08-01 and will not return.</p><p><a href="/catalogue/">Catalogue</a></p> </body></html>Below the status line is what a 410 needs and usually lacks: what went, when, and a way onward.
- Step 3.
Request the 410 that carries no body, the shape you get by default.
curl -sS -i http://127.0.0.1:9137/product/8HTTP/1.1 410 Gone content-type: text/html; charset=utf-8 Date: Fri, 11 Sep 2026 20:39:57 GMT Connection: keep-alive Keep-Alive: timeout=5 Transfer-Encoding: chunkedNothing follows the blank line. The headers match step 2 exactly, down to the content type promising HTML, so no header read separates the two. The reply was chunked and the stream ended at once, so there is not even a
content-length: 0to mark it. - Step 4.
Look at what a framework gives you before anyone configures it. Ask Express for an address nobody claimed.
curl -sS -i http://127.0.0.1:9138/product/17HTTP/1.1 404 Not Found X-Powered-By: Express Content-Security-Policy: default-src 'none' X-Content-Type-Options: nosniff Content-Type: text/html; charset=utf-8 Content-Length: 149 Date: Fri, 11 Sep 2026 20:40:26 GMT Connection: keep-alive <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>Error</title> </head> <body> <pre>Cannot GET /product/17</pre> </body> </html>A 404 arrives for free on every unclaimed path. No equivalent fallback exists for 410: the framework holds no record of what used to be there. Now ask for the route calling
res.sendStatus(410).curl -sS -i http://127.0.0.1:9138/product/9HTTP/1.1 410 Gone X-Powered-By: Express Content-Type: text/plain; charset=utf-8 Content-Length: 4 ETag: W/"4-FJ7Px29FxT+tzBhD32dV1K4lqio" Date: Fri, 11 Sep 2026 20:40:26 GMT Connection: keep-alive GoneFour bytes of plain text. A correct status with nothing behind it, which is why this procedure reads the body.
- Step 5.
Confirm what a failing assertion can see.
for p in 17 9; do curl -sS -f -o /dev/null http://127.0.0.1:9137/product/$p echo "/product/$p -> curl exit $?" donecurl: (22) The requested URL returned error: 404 /product/17 -> curl exit 22 curl: (22) The requested URL returned error: 410 /product/9 -> curl exit 22One exit code for both. A suite asserting that the request failed passes on either status, so a 410 that regressed to 404 goes unseen. Assert
%{http_code}instead. - Step 6.
Read all four URLs in one pass.
node removal-check.mjs http://127.0.0.1:9137/product/42 http://127.0.0.1:9137/product/17 http://127.0.0.1:9137/product/9 http://127.0.0.1:9137/product/8200 NOT A REMOVAL 190 bytes body-usable=true /product/42 404 UNKNOWN 231 bytes body-usable=true /product/17 410 PERMANENT 231 bytes body-usable=true /product/9 410 PERMANENT 0 bytes body-usable=false /product/8The last line is the defect this page exists for: the signal is right and the response is empty.
- Step 7.
Stop both servers by the PIDs
netstatreported.taskkill //PID 40700 //F; taskkill //PID 29152 //F; netstat -ano | grep LISTENING | grep 913SUCCESS: The process with PID 40700 has been terminated. SUCCESS: The process with PID 29152 has been terminated.No line after the success messages means neither port is held. The doubled slashes are for Git Bash, which otherwise rewrites
/PIDinto a Windows path and leavestaskkillreporting an invalid option while the server runs on.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| 404 with a page a visitor can use | The resource is missing and the server does not say why | Correct for a typo, a moved page, or anything that may return |
| 410 with a page a visitor can use | The removal is stated as permanent | Correct for a deletion nobody will reverse |
| 410 with an empty body or four bytes of text | The status is right and the response is unusable in a browser | Point the route at the template the 404 uses, with removal wording |
| 200 on a URL you deleted | Not a removal at all, whatever the body says | Follow How to check soft 404 |
| 301 to the home page | The removal was answered with a redirect to something unrelated | Redirect to a replacement, or answer 410. The home page is not a replacement |
| 404 and 410 both exit 22 under curl --fail | The suite cannot tell the two apart | Assert %{http_code}, not success or failure |
Common mistakes
What to check next
- How to check soft 404: the opposite defect, a missing page answering 200.
- How to test a 404 page: this page covers the signal, that one the page a visitor lands on.
- How to check redirect chain: for removals answered with a redirect.
- How to check if a page is indexable: the same URL from the indexing side.
- How to check if a url is blocked by robots.txt: a disallowed path is never fetched, so the status is never read.
FAQ
Difference between 409 and 410?
409 Conflict says the request cannot be applied to the resource's current state, such as an edit against a stale version. The resource is still there. 410 says it is gone for good. Testers meet 409 in API write paths, 410 in content removal.
Should a deleted page return 404 or 410?
410 when the deletion is final and the address will not be reused. 404 when the removal might be reversed or nobody can answer the question. MDN says the same: use 404 if you do not know whether the condition is permanent.
Does a 410 get a page out of search results faster than a 404?
No single request can measure that. What this settles is what each status states: 410 declares permanence and tells clients not to repeat the request, 404 declares absence alone.
Why does my framework return 404 for a page I deleted?
An unmatched address is the framework's fallback, and the framework holds no memory of what used to be there. Step 4 shows Express answering Cannot GET /product/17 for a route never defined. A 410 needs a route, a lookup table, or a proxy rule.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2express 5.2.1
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
basic6 minpublished updated Maks Verny