How HTTP Works

Method · safe · idempotent

> HEAD /files/report.pdf HTTP/1.1>  Host: cdn.example.com

HTTP HEAD Method

Learn how HTTP HEAD requests retrieve resource metadata (headers) without downloading the body. Useful for checking existence, size, and modification dates.

Reviewed 5 min readintermediate4 sourcesMarkdown
Safe
Yes
Idempotent
Yes
Cacheable
Yes
Request body
No defined semantics
Response body
No (headers only)
Spec
RFC 9110 §9.3.2
On this page

TL;DR: HEAD is GET without the body. The server returns the same status and headers it would for GET, so you can check a file’s size, type or ETag, or whether a URL exists, without downloading it. Servers may leave out headers they only know after generating the body, and HEAD often runs the same server code as GET, so it saves bandwidth more than server time. In curl, use curl -I, not -X HEAD.

What is HEAD?

HEAD gives you the outcome of a GET without the content. Everything else about the request works the same way: authorization and content negotiation still apply, and an endpoint’s access checks cover HEAD just as they cover GET.

RFC 9110 §9.3.2 says the server should send the same headers it would for GET. It may leave out ones it only learns while generating the content, such as Content-Length or Vary.

Key Characteristics

HEAD is safe and idempotent. Logging it is fine, and the headers change when the resource does. A HEAD response never has a body, even when it’s an error. If it includes Content-Length, that’s the size the matching GET body would be, not zero.

How HEAD Works

Here’s an exchange for a file whose GET body is the five bytes hello (examples on this page are trimmed):

HEAD /hello.txt HTTP/1.1
Host: example.com
HTTP/1.1 200 OK
Date: Mon, 05 Oct 2026 12:00:00 GMT
Content-Type: text/plain; charset=utf-8
Content-Length: 5
ETag: "hello-v1"

The response ends after the blank line. Some tools print a placeholder like [No body content], but that’s the tool talking; nothing like it appears on the wire.

Real-World Examples

Use curl’s HEAD mode:

curl --head 'https://example.com/'

Skip curl -X HEAD. It changes the method name but leaves curl expecting a body, so it hangs waiting for bytes that never come. --head (or -I) tells curl there’s no body. See the curl manual.

To compare HEAD with GET, request the same representation both ways and save GET’s headers separately from its content:

curl --head -H 'Accept-Encoding: identity' 'https://example.com/report.pdf'
curl -D ./get-headers.txt -o ./report.pdf \
  -H 'Accept-Encoding: identity' 'https://example.com/report.pdf'
wc -c ./report.pdf

When GET’s headers include Content-Length, it should match the downloaded byte count. Send the same Accept-Encoding, Accept and authorization on both requests; a gzip HEAD and an uncompressed GET will report different sizes, and that’s expected.

When to Use HEAD

Reach for HEAD to check a download’s size and type, or to test whether a link works. The answer is a snapshot, and the file may change before your GET. A missing Content-Length means the size is unknown, not zero. And if you’re going to fetch the content right away anyway, a HEAD first just adds a round trip.

HEAD vs GET

HEAD saves sending the body. On the server it often runs the same handler, database query or template as GET, so measure before you count on lower latency. For health checks, a 200 from HEAD only tells you the status; if you need to confirm the body is valid JSON with the expected result, use GET.

Common Use Cases

A conditional HEAD returns 304 when the ETag still matches:

curl --head 'https://api.example.com/catalog' \
  -H 'If-None-Match: "catalog-v7"'

Use the validator from an earlier response. For cross-origin checks from the browser, CORS has to allow the request, and the server must send Access-Control-Expose-Headers: ETag for your JavaScript to read ETag.

Checking download metadata in browser code

This checks a size limit before starting a download, treating a missing length as unknown:

async function inspectDownload(url, maxBytes) {
  const response = await fetch(url, { method: 'HEAD' })
  if (!response.ok) throw new Error(`HEAD failed: ${response.status}`)
  const rawLength = response.headers.get('Content-Length')
  const bytes = rawLength === null ? null : Number(rawLength)
  if (bytes !== null && (!Number.isSafeInteger(bytes) || bytes < 0)) {
    throw new Error('Invalid Content-Length')
  }
  if (bytes !== null && bytes > maxBytes) throw new Error('Download exceeds limit')
  return { bytes, etag: response.headers.get('ETag') }
}

Use it for UI feedback; enforce real limits while downloading. The file may change before the GET, and with compression the bytes on the wire differ from the decoded size. To make sure you download the version you inspected, send the ETag in If-Match on the GET (if the server supports preconditions). On a 412, refresh the metadata instead of downloading a version you didn’t check.

For a link checker, look at the status and the final URL. Fetch follows redirects by default, so a redirect to a login page comes back as a 200. Use curl without -L to see the first response, then curl --head -L URL to see the whole chain. A HEAD-only checker can’t check links inside the page either, since it never gets the HTML.

Common Response Codes

200 means the GET would succeed, and here are its headers. 304 answers a conditional check. 405 means the resource doesn’t allow HEAD, and it must include an Allow header. A 404 means the resource isn’t there, or the server won’t say.

Important Headers in HEAD Responses

Content-Type describes what GET would return. Content-Encoding tells you whether the size is compressed. ETag and Last-Modified let you validate a cached copy. Expect some endpoints to send only a few of these, and compare ETags only between responses for the same negotiated representation.

Getting HEAD right

Express runs your GET route for HEAD requests unless a HEAD route was registered before it. In this example Express computes the headers from the JSON it would send, then drops the body for HEAD:

const express = require('express')
const app = express()
app.get('/metadata', (_req, res) => {
  res.json({ name: 'catalog' })
})
app.listen(3000)

See Express routing. If you set headers yourself, compute the length with Buffer.byteLength(body, 'utf8'), since a JavaScript string’s length counts UTF-16 code units, not bytes. And make sure the headers describe the body you actually send: setting a stored file’s size as Content-Length and then returning JSON about the file produces a broken response.

Go file serving

Go’s http.ServeContent handles HEAD, byte ranges and conditional requests for any seekable file. Let it build the headers from the file it serves:

package main

import (
    "log"
    "net/http"
    "os"
)

func main() {
    http.HandleFunc("GET /report.pdf", func(w http.ResponseWriter, r *http.Request) {
        file, err := os.Open("./files/report.pdf")
        if err != nil {
            http.NotFound(w, r)
            return
        }
        defer file.Close()
        info, err := file.Stat()
        if err != nil {
            http.Error(w, "Cannot stat report", http.StatusInternalServerError)
            return
        }
        http.ServeContent(w, r, info.Name(), info.ModTime(), file)
    })
    log.Fatal(http.ListenAndServe(":8080", nil))
}

A GET ServeMux pattern matches HEAD too. Go’s documentation explains how ServeContent works out the size and handles validators. It won’t make up an ETag; set one yourself only if your application can identify that exact representation.

Try It Yourself

Compare HEAD and GET in the playground. On your own server, run both through curl and note which headers only appear once the body has been generated.

Try an error path too: curl --head 'https://example.com/missing-report.pdf' should get a 404 with no error-page body. Its Content-Type, if present, describes the error page GET would return.

In practice

Express.js
// Express answers HEAD with your GET route: same status and headers, no body.
const path = require('node:path')
app.get('/files/:name', (req, res, next) => {
  // sendFile derives Content-Length, Content-Type, Last-Modified and ETag
  // from the file itself, so HEAD and GET always agree. `root` blocks ../ traversal.
  res.sendFile(req.params.name, { root: path.join(__dirname, 'files') }, (err) => {
    if (err) next(err)
  })
})
Next.js App Router
// app/api/files/[id]/route.ts
// Export HEAD handler explicitly
export async function HEAD(
  _req: Request,
  { params }: { params: { id: string } }
) {
  const file = await storage.stat(params.id)
  if (!file) return new Response(null, { status: 404 })
  return new Response(null, {
    headers: {
      'Content-Length': String(file.size),
      'Content-Type': file.mimeType,
      'ETag': file.etag,
      'Last-Modified': file.updatedAt.toUTCString()
    }
  })
}
Go net/http
// Go 1.22+: a "GET" pattern also matches HEAD.
http.HandleFunc("GET /files/{name}", func(w http.ResponseWriter, r *http.Request) {
    f, err := os.Open(filepath.Join("files", filepath.Base(r.PathValue("name"))))
    if err != nil {
        http.NotFound(w, r)
        return
    }
    defer f.Close()
    info, err := f.Stat()
    if err != nil {
        http.Error(w, "stat failed", http.StatusInternalServerError)
        return
    }
    // ServeContent sets Content-Length, Content-Type and Last-Modified, handles
    // Range and If-Modified-Since, and writes no body for HEAD.
    http.ServeContent(w, r, info.Name(), info.ModTime(), f)
})

Frequently asked questions

What is the HTTP HEAD method?

HEAD asks for exactly what GET would return, minus the body. You get the status and headers, such as Content-Type, Content-Length and ETag, without downloading the content.

When should I use HEAD instead of GET?

When you only need the headers: checking a file’s size or type before downloading, or whether a link still works. Treat a missing Content-Length as unknown size, and remember the resource may change between your HEAD and a later GET.

Does HEAD return the same headers as GET?

The server should send the same header fields as GET, but it may omit values determined only while generating content. If Content-Length is present, it must describe the corresponding GET content length.

Is HEAD safe and idempotent?

Yes. HEAD only reads, so it is safe and idempotent. The server may still log it, and two HEAD responses can differ if the resource changed in between.

Sources

  1. MDN Web Docs: HEADdeveloper.mozilla.org
  2. RFC 9110: HEADrfc-editor.org
  3. Express: GET routing and HEAD handlingexpressjs.com
  4. curl: HEAD modecurl.se

Keep going

Browse /search