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

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

  1. Step 1.

    Read the whole 404 response, headers and body.

    curl -sS -i http://127.0.0.1:9137/product/17
    
    HTTP/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.

  2. Step 2.

    Read the 410 the same way.

    curl -sS -i http://127.0.0.1:9137/product/9
    
    HTTP/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.

  3. 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/8
    
    HTTP/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
    

    Nothing 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: 0 to mark it.

  4. 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/17
    
    HTTP/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/9
    
    HTTP/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
    
    Gone

    Four bytes of plain text. A correct status with nothing behind it, which is why this procedure reads the body.

  5. 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 $?"
    done
    
    curl: (22) The requested URL returned error: 404
    /product/17 -> curl exit 22
    curl: (22) The requested URL returned error: 410
    /product/9 -> curl exit 22

    One 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.

  6. 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/8
    
    200 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/8

    The last line is the defect this page exists for: the signal is right and the response is empty.

  7. Step 7.

    Stop both servers by the PIDs netstat reported.

    taskkill //PID 40700 //F; taskkill //PID 29152 //F; netstat -ano | grep LISTENING | grep 913
    
    SUCCESS: 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 /PID into a Windows path and leaves taskkill reporting 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

Sign: The route answers 410 and the browser shows a blank white page.Cause: A 410 in most frameworks is a status and nothing else. Node's http module sends the status line and ends the response, which produces a chunked reply with no content and no content-length to signal it. Express answers res.sendStatus(410) with four bytes of text/plain reading Gone. Both are correct HTTP, neither is the site's error page, and nothing in the framework reports a problem.
Sign: A page is taken down for two weeks, answers 410 while it is away, and returns to a fraction of its traffic.Cause: MDN states that a 410 response is cacheable by default and that clients should not repeat requests for resources that return it. Neither line appears on its 404 page. A 410 on an address that comes back relies on every client ignoring the instruction it was handed. While the outcome is undecided, 404 is the status that says so.
Sign: The monitor calls the removals healthy and a tester calls them broken.Cause: The monitor asserts that the request failed, and curl exits 22 for 404 and 410 alike. Only the message text differs. Any assertion built on an exit code, an HTTPError class or response.ok collapses the two statuses into one.
Sign: After a bulk cleanup every unknown address answers 410, including ones nobody meant to remove.Cause: A catch-all route that answers 410 covers every unmatched path, not only the deleted ones. A mistyped link then claims permanence about a URL that was never published. Keep the catch-all on 404 and list the removed addresses explicitly.

What to check next

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.

basic6 minpublished updated Maks Verny