# How HTTP Works: full text Every content page from https://howhttpworks.com, as Markdown. Each page starts with its canonical Source URL; cite that URL. --- # HTTP CONNECT Method: Proxy Tunnels Explained > HTTP CONNECT opens a TCP tunnel through a proxy so HTTPS can pass untouched. See the exact request and 200 response, 407 proxy auth, and extended CONNECT. Source: https://howhttpworks.com/methods/connect Last reviewed: 2026-10-04 > **TL;DR:** `CONNECT host:port` tells a proxy to open a TCP connection to that destination and then get out of the way. A `2xx` reply means the tunnel is up and the next bytes are not HTTP anymore, which is how HTTPS works through a corporate or forward proxy. ## What the exchange looks like A browser behind an HTTP proxy that wants `https://example.com/` does not send a GET to the proxy. It asks for a tunnel first: ```http CONNECT example.com:443 HTTP/1.1 Host: example.com:443 Proxy-Authorization: Basic dXNlcjpwYXNz ``` ```http HTTP/1.1 200 Connection Established ``` Everything after that blank line belongs to the client and the origin. The client starts a TLS handshake, the proxy copies bytes without reading them, and the `GET /` is encrypted inside the tunnel. The proxy only ever learned `example.com:443`. Rules worth knowing from [RFC 9110 section 9.3.6](https://www.rfc-editor.org/rfc/rfc9110#section-9.3.6): - The request target is authority-form, `host:port`, never a path or full URL. The port is required. - Any `2xx` means "tunnel established". The reason phrase is free text; Squid and others send `Connection Established`, which is where the common spelling comes from. - A server must not send `Content-Length` or `Transfer-Encoding` in a successful CONNECT response, and a client must ignore them if it sees them. - Anything other than `2xx` is an ordinary HTTP response and the connection is not a tunnel. - CONNECT is neither safe nor idempotent in the RFC 9110 sense, and its responses are not cacheable. ## Proxy authentication and refusals If the proxy requires credentials it answers [407](https://howhttpworks.com/status-codes/407) with a `Proxy-Authenticate` challenge, and the client retries with [Proxy-Authorization](https://howhttpworks.com/headers/proxy-authorization): ```http HTTP/1.1 407 Proxy Authentication Required Proxy-Authenticate: Basic realm="corp-proxy" Content-Length: 0 ``` A bare `403` on CONNECT is a policy decision. Forward proxies commonly limit which destination ports can be tunnelled, because an open CONNECT to arbitrary ports turns the proxy into a relay to SMTP, SSH or internal services. Squid's stock configuration, for instance, denies CONNECT to ports outside its `SSL_ports` list. If HTTPS to port 443 works but `https://host:8443/` fails with a 403 from the proxy, that rule is the first thing to check. ## Reproduce it curl uses CONNECT automatically when you give it an HTTP proxy and an HTTPS URL: ```bash curl -v -x http://proxy.example.net:3128 https://example.com/ -o /dev/null ``` The abridged verbose output shows the tunnel step before the TLS handshake: ```text * Establish HTTP proxy tunnel to example.com:443 > CONNECT example.com:443 HTTP/1.1 > Host: example.com:443 > Proxy-Connection: Keep-Alive > < HTTP/1.1 200 Connection established < * CONNECT phase completed ``` Add `-U user:pass` (`--proxy-user`) to send `Proxy-Authorization`. Lines marked `>` and `<` before the TLS handshake are proxy traffic; the origin's own headers appear after it. ## HTTP/2 and HTTP/3 Over HTTP/2, CONNECT is one stream rather than the whole connection, so one TCP connection to the proxy can carry many tunnels. [RFC 9113 section 8.5](https://www.rfc-editor.org/rfc/rfc9113#section-8.5) defines the request with `:method` = `CONNECT` and `:authority` = `host:port`; `:scheme` and `:path` are omitted. HTTP/3 uses the same shape (RFC 9114). ### Extended CONNECT: WebSockets over HTTP/2 and HTTP/3 [RFC 8441](https://www.rfc-editor.org/rfc/rfc8441) adds a `:protocol` pseudo-header to CONNECT so a stream can bootstrap another protocol. The server must first advertise `SETTINGS_ENABLE_CONNECT_PROTOCOL` with value 1, and a client must not use `:protocol` until it has seen that setting. A WebSocket handshake then looks like this (HTTP/2 pseudo-headers shown): ```text :method = CONNECT :protocol = websocket :scheme = https :path = /chat :authority = server.example.com sec-websocket-version = 13 origin = https://example.com ``` The server answers `200`, not `101 Switching Protocols`, and the stream then carries WebSocket frames. Note that in extended CONNECT `:scheme` and `:path` are present again. [RFC 9220](https://www.rfc-editor.org/rfc/rfc9220) is the same mechanism for HTTP/3. If a WebSocket works on HTTP/1.1 but breaks after you enable HTTP/2 at a proxy, check whether the proxy advertises extended CONNECT; clients that do not see the setting fall back to the classic [Upgrade](https://howhttpworks.com/headers/upgrade) handshake on an HTTP/1.1 connection. ### CONNECT-UDP and CONNECT-IP (MASQUE) The IETF MASQUE working group builds on extended CONNECT to proxy things other than TCP. [RFC 9298](https://www.rfc-editor.org/rfc/rfc9298) defines `:protocol = connect-udp`, which proxies UDP datagrams (and therefore QUIC and HTTP/3) through an HTTP proxy. RFC 9484 does the same for IP packets with `connect-ip`. Both use the capsule protocol from RFC 9297. These are client-to-proxy protocols; an application server does not need to implement them. ## Browsers and servers - Browser JavaScript cannot send CONNECT. The Fetch Standard treats `CONNECT`, `TRACE` and `TRACK` as forbidden methods. - A normal origin server or reverse proxy has no reason to accept CONNECT. Stock nginx is not a forward proxy and does not implement tunnelling for it; third-party modules such as `ngx_http_proxy_connect_module` exist for that purpose. Squid, Envoy and most cloud forward proxies do support it. - If CONNECT requests appear in your origin's access log, they are usually scanners probing for an open proxy. A `405` or a dropped connection is the right answer. ## Related [OPTIONS](https://howhttpworks.com/methods/options) is the other method that does not fit the read/write mould. For the proxy-side status code see [407 Proxy Authentication Required](https://howhttpworks.com/status-codes/407). --- # HTTP DELETE Method: Remove Resources > Learn how the HTTP DELETE method works, when to use it, and best practices for deleting resources in REST APIs. Source: https://howhttpworks.com/methods/delete Last reviewed: 2026-10-05 > **TL;DR:** DELETE removes the resource at a URL. Return 204 when it's gone, 200 if you send back a body, or 202 if deletion happens later. It's idempotent: a second DELETE leaves the same state, even if it returns 404. The server decides whether that means erasing data or just hiding it. ## What is DELETE? DELETE targets whatever the URL identifies. That might be a database row or a file, but it doesn't have to be. The server can archive the data, keep an audit record, or refuse. [RFC 9110 §9.3.5](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.5) leaves the underlying storage up to the server. ```bash curl -i -X DELETE 'https://api.example.com/documents/notes' ``` A successful deletion usually looks like this (responses on this page are trimmed examples): ```http HTTP/1.1 204 No Content Date: Mon, 05 Oct 2026 12:00:00 GMT ``` A 204 carries no body and no `Content-Length`. ## Idempotency Delete the same resource twice and you end up in the same place: it's gone. The status codes don't have to match. The first call can return 204 and the second 404, and that's still idempotent, because idempotency is about server state, not the response. This assumes nobody recreated the resource between calls. Logging each attempt is fine too. ## Response Codes Return 204 when the resource is gone and there's nothing to say, 200 when you return a body describing the result, or 202 when you've queued the deletion but haven't done it yet. For a resource that's already gone, return 404 (which also covers resources the caller may not see) or 204 so retries look like success. Pick one and document it. If deletion is blocked by dependent data, return 409 Conflict. If the client sent `If-Match` and the version no longer matches, return 412 Precondition Failed. ## Implementation This Express server keeps documents in memory and returns 404 on a repeated delete: ```javascript const express = require('express') const app = express() const documents = new Map([['notes', { title: 'Notes' }]]) app.delete('/documents/:id', (req, res) => { if (!documents.delete(req.params.id)) { return res.status(404).json({ error: 'Not found' }) } res.status(204).end() }) app.listen(3000) ``` Run `curl -i -X DELETE http://localhost:3000/documents/notes` twice and you'll get 204, then 404. It has no authentication, so add the access checks described below before exposing anything private. If your API's contract says 404 means "already deleted", the client can treat it as success: ```javascript async function deleteDocument(id) { const response = await fetch(`/api/documents/${encodeURIComponent(id)}`, { method: 'DELETE' }) if (response.status === 202) return { pending: true } if (response.status === 404 || response.status === 204) return { pending: false } if (!response.ok) throw new Error(`DELETE failed: ${response.status}`) return { pending: false } } ``` Check the contract before reusing that 404 rule elsewhere: some services return 404 to hide resources you can't access. The helper also never parses a 204 as JSON. ## Handling Dependencies When a resource has children, decide whether deleting it rejects, cascades, or detaches them. Enforce that in the database, with a constraint or inside the delete's transaction. A count query followed by a separate delete lets another request insert a child in the gap. For soft deletion in PostgreSQL, add a condition so a retry keeps the original deletion timestamp: ```sql UPDATE posts SET deleted_at = CURRENT_TIMESTAMP WHERE id = $1 AND deleted_at IS NULL; ``` Put the authorization check in the same statement or transaction. For dependencies, a count in application code can't replace a foreign-key constraint, since a concurrent insert can land right after it. Document whether dependencies cause 409, get detached, or are removed with the parent. ## Bulk Delete Sending a JSON array of IDs in a DELETE body is tempting, but a DELETE body has no defined meaning in HTTP and some servers and proxies reject it. Use a POST to a dedicated endpoint instead: ```bash curl -i 'https://api.example.com/document-deletions' \ -H 'Content-Type: application/json' \ --data '{"ids":["notes","draft"]}' ``` That endpoint is your API's design, not an HTTP standard. Give it its own retry policy and check authorization for each document. ## Getting DELETE right Only parse JSON when the status has a body; 204 doesn't. A 202 means deletion is still pending. To delete only the version the client inspected, send the strong ETag from GET in `If-Match`, and check it atomically with the delete. DELETE responses aren't cacheable. A successful DELETE invalidates the cached response for that URL, but not collection pages that list the resource. See [RFC 9111 §4.4](https://www.rfc-editor.org/rfc/rfc9111.html#section-4.4). ## Security Considerations for DELETE Endpoints Check that the caller may delete this specific resource, not just that a session exists. Otherwise anyone who guesses an ID can delete it. Cookie-authenticated endpoints also need CSRF protection; CORS alone won't provide it. With soft deletes, keep the original `deletedAt` on retries, as the SQL above does. Hiding a row also isn't the same as erasing personal data from backups and other stores. ## Related - [Idempotent](https://howhttpworks.com/glossary/idempotent) - [If-Match](https://howhttpworks.com/headers/if-match) - [204 No Content](https://howhttpworks.com/status-codes/204) --- # HTTP GET Method: Complete Guide with Examples > Learn how the HTTP GET method works. Understand when to use GET requests, query parameters, caching, and best practices with real-world examples. Source: https://howhttpworks.com/methods/get Last reviewed: 2026-10-05 > **TL;DR:** GET fetches whatever lives at a URL: a page, an image, a JSON record. It should never change server state, which is why browsers, crawlers, and caches feel free to repeat and prefetch it. Put parameters in the query string, not a body. Responses are cacheable when the status and `Cache-Control` headers allow it. ## What is GET? A GET asks for the current version of whatever is at a URL. It shouldn't change anything. `GET /orders/42/cancel` breaks that contract even if the handler works, because crawlers and link prefetchers follow GET links freely, without asking anyone to approve a write. Requests and responses on this page are examples: ```http GET /products?category=books HTTP/1.1 Host: api.example.com Accept: application/json ``` [RFC 9110 §9.3.1](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.1) gives a GET body no defined meaning, and the browser Fetch API refuses to send one. For a lookup, query parameters are the portable choice. ## Key Characteristics "Safe" is about what the client asks for, not every side effect. Logging a GET or bumping a usage counter is fine. "Idempotent" is about the intended effect on server state, not identical bytes: a second GET can return a newer price or a different status. GET responses are cacheable, though not every one gets stored. `Cache-Control: no-store` keeps a response out of caches; `no-cache` lets caches store it but makes them revalidate before each reuse. Freshness is measured against the response's age, which includes time spent in upstream caches, not just time since your browser received it. See [HTTP caching](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching). ## Query Parameters Let curl encode values instead of assembling the query by hand: ```bash curl --get 'https://api.example.com/products' \ --data-urlencode 'q=red shoes' \ --data-urlencode 'category=books' ``` The endpoint decides what `q` and `category` mean; HTTP has no standard names for pagination or filters. Keep credentials out of the URL. HTTPS encrypts it in transit, but URLs still land in server logs and browser history. In browser code, build the URL with `URLSearchParams`. Use `append()` when the endpoint expects a repeated key for a multi-value filter: ```javascript const url = new URL('/api/products', location.origin) url.searchParams.set('q', 'red shoes') url.searchParams.set('page', '2') url.searchParams.append('tag', 'books') url.searchParams.append('tag', 'sale') console.log(url.search) ``` Pass raw values to `set()`; it does the percent-encoding, so encoding them yourself first double-encodes them. Likewise, a literal ampersand in a search term goes into the value, where it gets encoded, rather than being concatenated into the query string where it would split the parameter. The [URLSearchParams documentation](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) covers both behaviors. ## Real-World Examples Look at the headers and body together, then repeat the request with the ETag your server actually returned: ```bash curl -i 'https://api.example.com/products/42' curl -i 'https://api.example.com/products/42' \ -H 'If-None-Match: "catalog-v7"' ``` If that validator still matches, you get: ```http HTTP/1.1 304 Not Modified Date: Mon, 05 Oct 2026 12:00:00 GMT ETag: "catalog-v7" Cache-Control: max-age=60 ``` A 304 has no body; the client reuses the copy it already has. When both validators are sent, `If-None-Match` takes precedence over `If-Modified-Since`. ### Serving a downloadable file In Express, let the file-serving API send the file and its headers together. This runnable example serves one fixed file, so no request parameter can point it at an arbitrary path on disk: ```javascript const express = require('express') const path = require('node:path') const app = express() app.get('/downloads/report', (_req, res, next) => { res.download(path.join(__dirname, 'files/report.pdf'), 'report.pdf', (error) => { if (error) next(error) }) }) app.listen(3000) ``` Create `files/report.pdf` before testing. `res.download()` sends the file with an attachment disposition, so the browser saves it. `res.attachment('report.csv')` only sets headers, and you still have to send the content yourself, for example `res.attachment('report.csv').send('name\nAvery\n')`. Use `res.sendFile()` when you want file serving without forcing attachment. [Express documents these APIs](https://expressjs.com/en/5x/api/response/#res.download), including the need to handle errors after a transfer has partially started. In Django, a view can return a binary file with `FileResponse`: ```python from pathlib import Path from django.conf import settings from django.http import FileResponse from django.views.decorators.http import require_safe @require_safe def download_report(request): report = Path(settings.BASE_DIR) / "files" / "report.pdf" return FileResponse(report.open("rb"), as_attachment=True, filename="report.pdf") ``` Map the view in your URL configuration. Open the file without a `with` block: [FileResponse closes it](https://docs.djangoproject.com/en/5.2/ref/request-response/#fileresponse-objects) once it's served, and a `with` block would close it too early. If the report is private, check authorization before opening it. ## Common Headers `Accept` says which media type the client wants; `Content-Type` says what it got. `ETag` identifies a version for conditional requests. `Vary: Accept-Encoding` tells caches that the response depends on that request header. Bearer tokens go in `Authorization`, not a query parameter. None of these are required on every GET; they're the ones you'll reach for when debugging: | Header | Example value | Debugging use | | --- | --- | --- | | Accept | `application/json` | Compare what the client requested with the returned Content-Type. | | Accept-Language | `en-US,en;q=0.9` | Check whether language negotiation changes the representation. | | Accept-Encoding | `gzip, br` | Compare compressed and uncompressed variants. | | If-None-Match | `"catalog-v7"` | Validate the representation already stored by the client. | | Cache-Control | `no-cache` | Request validation before a stored response is reused. | For a download, `curl -D - -o ./report.pdf URL` writes the body to disk while leaving headers visible. `curl --compressed` asks for a compressed encoding curl supports and decodes what comes back. When the bug is in the GET body, test with GET; HEAD won't show it. ## Response Status Codes A successful retrieval commonly returns [200](https://howhttpworks.com/status-codes/200); a successful byte-range request can return [206](https://howhttpworks.com/status-codes/206). A conditional request can return [304](https://howhttpworks.com/status-codes/304). A [404](https://howhttpworks.com/status-codes/404) sometimes hides a resource the server won't admit exists, so it doesn't mean the URL never existed. ## GET vs POST GET reads. POST asks the server to process what you send, however that resource defines it. POST can carry a large search document that won't fit comfortably in a query string, but its responses are much harder to cache. Neither method encrypts anything; that's HTTPS's job. There's no universal 2,000-character URL limit. Each hop sets its own. For example, nginx requires a request line to fit in one `large_client_header_buffers` buffer and returns 414 otherwise; its [documented default](https://nginx.org/en/docs/http/ngx_http_core_module.html#large_client_header_buffers) is `4 8k`. Four 8k buffers still mean a single request line has to fit in 8k, not 32k. ## Getting GET right If opening or refreshing a URL changes business state, change the handler or the method. Adding `?action=delete` to the URL doesn't make a mutating GET safe. When a response contains account data, set an explicit cache policy such as `Cache-Control: private` or `no-store` instead of hoping the URL keeps it private. ## JavaScript Examples ```javascript const params = new URLSearchParams({ q: 'red shoes', category: 'books' }) const response = await fetch(`/api/products?${params}`) if (!response.ok) throw new Error(`GET failed: ${response.status}`) const products = await response.json() ``` Fetch resolves normally on HTTP errors like 404, so check `ok` before you parse the body as a success payload. A failed request and an HTTP error are different paths. Keep the status visible in the error you report: ```javascript async function fetchDocument(id) { const response = await fetch(`/api/documents/${encodeURIComponent(id)}`, { headers: { Accept: 'application/json' } }) if (response.status === 404) throw new Error('Document not found') if (!response.ok) throw new Error(`HTTP ${response.status}`) return response.json() } ``` If `json()` throws, check Content-Type and the body in DevTools. A reverse proxy or CDN may return an HTML error page from a URL that normally returns JSON; your API's contract only covers the responses your app generates. ## Try It Yourself Open the [GET playground preset](https://howhttpworks.com/tools/playground?preset=get-one), change the query, and watch the request URL change. When debugging caching on your own server, compare the first response's ETag with the validator sent on the next request. On a conditional GET, check that the 304 comes back with no body. If your code sends its own `If-None-Match` and then calls `json()` on a 304, it will try to parse an empty body instead of using the copy it already has. ## Related Methods - [HEAD](https://howhttpworks.com/methods/head): retrieval metadata without response content. - [POST](https://howhttpworks.com/methods/post): process submitted content. ## Related Concepts - [Cache-Control](https://howhttpworks.com/headers/cache-control) - [If-None-Match](https://howhttpworks.com/headers/if-none-match) - [Vary](https://howhttpworks.com/headers/vary) --- # HTTP HEAD Method > Learn how HTTP HEAD requests retrieve resource metadata (headers) without downloading the body. Useful for checking existence, size, and modification dates. Source: https://howhttpworks.com/methods/head Last reviewed: 2026-10-05 > **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](https://www.rfc-editor.org/rfc/rfc9110.html#section-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): ```http HEAD /hello.txt HTTP/1.1 Host: example.com ``` ```http 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: ```bash 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](https://curl.se/docs/manpage.html#-I). To compare HEAD with GET, request the same representation both ways and save GET's headers separately from its content: ```bash 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: ```bash 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: ```javascript 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](https://howhttpworks.com/status-codes/200) means the GET would succeed, and here are its headers. [304](https://howhttpworks.com/status-codes/304) answers a conditional check. [405](https://howhttpworks.com/status-codes/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: ```javascript const express = require('express') const app = express() app.get('/metadata', (_req, res) => { res.json({ name: 'catalog' }) }) app.listen(3000) ``` See [Express routing](https://expressjs.com/en/5x/api/application/#app.get). 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: ```go 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](https://pkg.go.dev/net/http#ServeContent) 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](https://howhttpworks.com/tools/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. ## Related Methods - [GET](https://howhttpworks.com/methods/get) - [OPTIONS](https://howhttpworks.com/methods/options) ## Related Concepts - [ETag](https://howhttpworks.com/headers/etag) - [CORS](https://howhttpworks.com/guides/cors) --- # HTTP OPTIONS Method > Learn how HTTP OPTIONS requests discover server capabilities, supported methods, and handle CORS preflight checks for cross-origin requests. Source: https://howhttpworks.com/methods/options Last reviewed: 2026-10-05 > **TL;DR:** OPTIONS asks about communication options for a resource. Browsers also use it for CORS preflight. `Allow` advertises HTTP methods; `Access-Control-Allow-*` grants browser CORS permission. They are separate mechanisms. ## What is OPTIONS? `OPTIONS /users/42` targets one resource. `OPTIONS *` targets the server in general, rather than every route. [RFC 9110 §9.3.7](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.7) does not define a universal discovery response body. To address the server-wide asterisk form with curl: ```bash curl -i -X OPTIONS --request-target '*' 'https://api.example.com/' ``` Compare it with an OPTIONS request to `/users/42`. A server may return different capability information for those targets. Neither response is a catalog of every application route. ## Key Characteristics OPTIONS is safe and idempotent: it requests information rather than a business-state change. Its response can change when capabilities change. OPTIONS responses are not HTTP-cacheable. Browser preflight permissions are kept in a separate CORS preflight cache. ## How OPTIONS Works ```bash curl -i -X OPTIONS 'https://api.example.com/users/42' ``` Illustrative capability response: ```http HTTP/1.1 204 No Content Date: Mon, 05 Oct 2026 12:00:00 GMT Allow: GET, HEAD, PUT, DELETE, OPTIONS ``` A successful OPTIONS response should advertise applicable capabilities, such as `Allow`, but `Allow` is not mandatory on every OPTIONS response. It is mandatory on a 405 response. ## Real-World Examples Reproduce the shape of a browser preflight with curl: ```bash curl -i -X OPTIONS 'https://api.example.com/users/42' \ -H 'Origin: https://app.example.com' \ -H 'Access-Control-Request-Method: PUT' \ -H 'Access-Control-Request-Headers: content-type, authorization' ``` Curl shows what the server sends; it does not enforce browser CORS rules. ## When to Use OPTIONS Use it to inspect advertised capabilities or investigate a failed preflight. Do not infer user authorization from an allowed-method list: the actual PUT or DELETE still needs access checks. ## CORS Preflight Explained A cross-origin Fetch request using PUT, PATCH, DELETE, JSON content, or `Authorization` generally needs preflight unless a matching preflight permission is already cached. Same-origin requests do not need CORS preflight merely because they use these methods. The preflight carries the intended method and header names, not the Bearer token or cookies. The [Fetch Standard](https://fetch.spec.whatwg.org/#cors-preflight-fetch) requires a successful status for preflight. Authentication middleware that returns 401 before handling OPTIONS prevents the actual request. This cross-origin browser request needs permission for both its method and JSON request header: ```javascript const response = await fetch('https://api.example.com/users/42', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ displayName: 'Avery' }) }) if (!response.ok) throw new Error(`PUT failed: ${response.status}`) ``` In the Network panel, the preflight should advertise PUT in Access-Control-Request-Method and `content-type` in Access-Control-Request-Headers. These are requests for permission, not response grants. Adding Access-Control-Allow-Origin to this JavaScript request cannot grant itself access. ## Common Response Headers `Access-Control-Allow-Origin` grants access to one origin or, for requests whose credentials mode is not `include`, `*`. `Access-Control-Allow-Methods` lists permitted methods; `Access-Control-Allow-Headers` lists permitted request headers. `Authorization` must be named explicitly; the header wildcard does not cover it. ## Response Patterns Illustrative preflight response for a cookie-authenticated client: ```http HTTP/1.1 204 No Content Date: Mon, 05 Oct 2026 12:00:00 GMT Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Credentials: true Access-Control-Allow-Methods: PUT Access-Control-Allow-Headers: Content-Type, Authorization Access-Control-Max-Age: 600 Vary: Origin ``` Here 600 seconds is a chosen permission lifetime, not a browser default or guarantee. Browsers cap it. The actual response must also carry the required origin and credentials headers. ## Common Response Codes 200 can include a capability document; 204 has no content. A 405 needs `Allow`. For browser preflight, a non-success status such as 401 or 403 fails the check regardless of an otherwise correct allow-origin header. ## Getting OPTIONS right Keep preflight ahead of application authentication, then authenticate and authorize the actual operation. Match the requested origin against an allowlist. If the response changes by origin, include `Vary: Origin` so an HTTP cache does not reuse the wrong variant. ## CORS Configuration Examples This Express route handles preflight for one endpoint. The GET response also grants access: ```javascript const express = require('express') const app = express() app.use('/catalog', (_req, res, next) => { res.set('Access-Control-Allow-Origin', 'https://app.example.com') next() }) app.options('/catalog', (_req, res) => { res.set('Access-Control-Allow-Headers', 'Authorization') res.status(204).end() }) app.get('/catalog', (_req, res) => res.json({ items: [] })) app.listen(3000) ``` This fixed-origin example does not use cookies. A GET preflight caused by Authorization needs the allowed header; GET itself is a CORS-safelisted method. For a larger Express application, the maintained `cors` middleware handles preflight when mounted at application level: ```javascript const cors = require('cors') app.use(cors({ origin: ['https://app.example.com', 'https://admin.example.com'], methods: ['GET', 'HEAD', 'PUT'], allowedHeaders: ['Content-Type', 'Authorization'], credentials: true, maxAge: 600 })) ``` Mount it before the actual routes and their authentication middleware. This is an alternative to the manual handler above. [The middleware documentation](https://expressjs.com/en/resources/middleware/cors/) specifies the option names and application-level OPTIONS handling. The allowed-method list should describe methods your endpoint implements; CORS middleware does not add a PUT implementation. ### nginx in front of one API This fragment belongs inside an nginx server block. It grants one fixed frontend origin, handles OPTIONS locally, and proxies actual requests: ```nginx location /api/ { add_header Access-Control-Allow-Origin "https://app.example.com" always; add_header Access-Control-Allow-Methods "GET, HEAD, PUT" always; add_header Access-Control-Allow-Headers "Content-Type, Authorization" always; add_header Access-Control-Max-Age "600" always; if ($request_method = OPTIONS) { return 204; } proxy_pass http://127.0.0.1:3000; } ``` The [add_header `always` parameter](https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header) matters for error responses. Keep one layer responsible for CORS headers; an upstream that also adds allow-origin can produce duplicates. No credentials grant is configured here. If cookie credentials are needed, add the appropriate credentials policy to both exchanges. ## Debugging CORS Issues Inspect both OPTIONS and the actual response in DevTools. A valid preflight followed by a response without allow-origin still fails. An absent OPTIONS can mean no preflight was needed or a cached permission was reused. When OPTIONS succeeds but the operation fails, distinguish three cases: no actual request was sent, an actual request returned an HTTP error, or the browser refused to expose its response. In DevTools, look at the method, status, and headers for each entry rather than only the console message. A 405 with `Allow: GET, HEAD` means the endpoint does not allow OPTIONS. Returning that header alone does not make a browser preflight succeed. A 204 with a missing requested header grant can also fail despite its success status. ## Try It Yourself Use the [CORS debugger](https://howhttpworks.com/tools/cors-debugger) to compare GET with and without Authorization. Inspect `Access-Control-Request-Headers: authorization` in the simulated preflight. Removing Authorization from allow-headers should fail that permission check even if allow-origin is correct. ## Related Methods - [GET](https://howhttpworks.com/methods/get) - [PATCH](https://howhttpworks.com/methods/patch) ## Related Concepts - [CORS guide](https://howhttpworks.com/guides/cors) - [Preflight request](https://howhttpworks.com/glossary/preflight-request) --- # HTTP PATCH Method > Learn how HTTP PATCH requests apply partial modifications to resources. Understand JSON Patch, merge patch formats, and when to use PATCH vs PUT. Source: https://howhttpworks.com/methods/patch Last reviewed: 2026-10-05 > **TL;DR:** PATCH updates part of a resource by sending a change document instead of the whole thing. The `Content-Type` decides what the body means: `application/merge-patch+json` sends the fields to change, while `application/json-patch+json` sends a list of operations. PATCH isn't automatically idempotent, and the server has to apply the whole patch or none of it. ## What is PATCH? PATCH is defined in [RFC 5789 §2](https://www.rfc-editor.org/rfc/rfc5789.html#section-2). The difference from PUT is what you send: PUT sends the new state, PATCH sends instructions for getting there. A patch can touch more than one field, and some patch formats can even replace the entire document. ## Key Characteristics PATCH is unsafe, and it's only idempotent if its operations are. Setting a field to a fixed value gives the same result every time; appending to an array adds another element on every retry. A string like `"current + 1"` is just a string unless your API has explicitly defined that syntax as an operation. Atomicity is a requirement, not a nice-to-have. If any operation fails, none of the patch's changes get applied, and no other reader ever sees a half-patched resource. ## How PATCH Works Send the format your server says it supports. For a server that accepts JSON Merge Patch: ```bash curl -i -X PATCH 'https://api.example.com/users/42' \ -H 'Content-Type: application/merge-patch+json' \ -H 'If-Match: "user-v7"' \ --data '{"displayName":"Avery","temporaryNote":null}' ``` The ETag here is a placeholder, so GET the resource first to get its current strong validator. On the server side, merge patch is a specific algorithm with its own media type. Passing parsed JSON straight into a generic ORM update doesn't implement it. ## Real-World Examples JSON Patch can check a value before changing it: ```bash curl -i -X PATCH 'https://api.example.com/tickets/42' \ -H 'Content-Type: application/json-patch+json' \ -H 'If-Match: "ticket-v7"' \ --data '[{"op":"test","path":"/status","value":"open"},{"op":"replace","path":"/status","value":"closed"}]' ``` If the `test` fails, the ticket stays exactly as it was. This assumes the endpoint supports JSON Patch and the ticket has a `status` member. ### Settings and array updates On a merge-patch endpoint, a couple of boolean settings make a clean partial update: ```json {"emailNotifications":false,"pushNotifications":true} ``` Settings you leave out stay as they are. For tags, merge patch needs the complete array you want: ```json {"tags":["web","api","tutorial"]} ``` That replaces the old array outright; it won't merge the two lists or remove a single tag. If you need to change one array position, use JSON Patch, and add a precondition if another editor might have reordered the array in the meantime. ## When to Use PATCH Use PATCH when a documented change format fits better than sending the whole new state. PATCH can even create a resource, if the patch format can operate on something that doesn't exist yet and the server allows it. So "PATCH can never create" is too strong a rule. ## PATCH vs Other Methods PUT is idempotent by definition, while PATCH is only as idempotent as its operations. POST is for whatever processing the resource defines. Whichever method you pick, validation, authorization and concurrency control are still your job. ## PATCH Formats ### JSON Merge Patch [RFC 7396](https://www.rfc-editor.org/rfc/rfc7396.html) defines `application/merge-patch+json`. Objects merge recursively. A member set to `null` gets removed, not stored as a JSON null, and a member you leave out stays unchanged. Arrays are replaced wholesale, so you can't append one element with this format. An API that takes `application/json` with a subset of fields has its own contract, unless it says otherwise. Looking like merge patch doesn't make it merge patch, so name it after what it actually does. Here's why a shallow object spread isn't enough. Start with: ```json {"profile":{"name":"Avery","city":"London"},"tags":["web"],"temporaryNote":"draft"} ``` Apply this as `application/merge-patch+json`: ```json {"profile":{"city":"Paris"},"tags":["api"],"temporaryNote":null} ``` And you get: ```json {"profile":{"name":"Avery","city":"Paris"},"tags":["api"]} ``` The name survives because the merge is recursive, the tags array gets replaced, and the temporary note disappears. If your data model needs a member whose value really is `null`, that removal rule gets in the way. JSON Patch can set a value to null without treating it as a delete. ### JSON Patch [RFC 6902](https://www.rfc-editor.org/rfc/rfc6902.html) defines `application/json-patch+json`: an ordered array of `add`, `remove`, `replace`, `move`, `copy` and `test` operations, with paths written as JSON Pointers. An `add` to `/tags/-` appends to an existing array, so sending the same patch twice appends twice. `replace` only works if its target already exists. Each of the six operations has its own rules: | Operation | Concrete behavior | | --- | --- | | add | Add an object member or insert an array element; `/-` appends to an array. | | remove | Remove an existing target. | | replace | Replace an existing target. | | move | Remove at `from`, then add at `path`. | | copy | Copy the value at `from` to `path`. | | test | Compare the target with `value`; failure stops application. | JSON Pointer escapes `~` as `~0` and `/` as `~1`. So `/a~1b` points at a member literally named `a/b`, not at `b` inside `a`. If a patch works for ordinary field names but fails on user-defined keys, check the escaping. ## Common Response Codes A 200 can return the updated representation, and a 204 returns nothing. A failed `If-Match` normally gets [412](https://howhttpworks.com/status-codes/412). A conflict that isn't a failed precondition can get [409](https://howhttpworks.com/status-codes/409). For a patch media type the server doesn't support, send [415](https://howhttpworks.com/status-codes/415) with `Accept-Patch` listing the formats it does accept. A patch the server understands but can't apply can get [422](https://howhttpworks.com/status-codes/422). ## Getting PATCH right Ask the server what it supports with OPTIONS: ```bash curl -i -X OPTIONS 'https://api.example.com/users/42' ``` A response might look like this: ```http HTTP/1.1 204 No Content Date: Mon, 05 Oct 2026 12:00:00 GMT Allow: GET, HEAD, PATCH, OPTIONS Accept-Patch: application/merge-patch+json, application/json-patch+json ``` Run authorization and validation against the document that results from the patch, not only against the keys in the request. Check `If-Match` and write the change in one atomic step, so nothing can slip in between the check and the write. A PATCH response is cacheable only if it has explicit freshness information and a `Content-Location` that matches the target URI. Even then, the cached copy can answer later GET or HEAD requests, never another PATCH. In a browser client, hold on to the ETag you read with the document and send it back: ```javascript async function patchDocument(id, changes, etag) { const response = await fetch(`/api/documents/${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/merge-patch+json', 'If-Match': etag }, body: JSON.stringify(changes) }) if (response.status === 412) throw new Error('Document changed; reload before editing') if (!response.ok) throw new Error(`PATCH failed: ${response.status}`) return response.status === 204 ? null : response.json() } ``` A 412 means someone else changed the document, so retrying blindly would overwrite their work. Reload, reconcile the changes, and submit against the new version. A cross-origin client also needs the server to expose `ETag` and allow the `If-Match` request header. If you hit a 415, compare your exact media type with `Accept-Patch` before you touch the patch body. ## Try It Yourself Try a partial update with the [update preset](https://howhttpworks.com/tools/playground?preset=update). Check which Content-Type that endpoint accepts before you assume it implements either standard JSON patch format. On a merge-patch endpoint, compare sending `{"tags":["api"]}` with leaving tags out. The first replaces the array and the second leaves it alone. An endpoint with its own custom partial-update contract may behave differently. ## Related Methods - [PUT](https://howhttpworks.com/methods/put) - [POST](https://howhttpworks.com/methods/post) ## Related Concepts - [If-Match](https://howhttpworks.com/headers/if-match) - [Idempotent](https://howhttpworks.com/glossary/idempotent) --- # HTTP POST Method: Complete Guide with Examples > Learn how the HTTP POST method works. Understand when to use POST requests, request bodies, form submissions, and API calls with practical examples. Source: https://howhttpworks.com/methods/post Last reviewed: 2026-10-05 > **TL;DR:** POST sends data in the request body for the server to process: create a record, submit a form, upload a file, or trigger an action. Return `201 Created` with a `Location` header when it creates something. POST isn't idempotent, so retrying after a dropped connection can create a duplicate, and putting data in the body doesn't encrypt it. Use HTTPS for that. ## What is POST? [RFC 9110 §9.3.3](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.3.3) defines POST loosely: the target resource processes the request body according to its own rules. Creating a record is one common use, but not the definition. A POST can just as well compute and return a result without creating anything. This curl command sends JSON to an example API (requests and responses on this page are trimmed examples): ```bash curl -i 'https://api.example.com/documents' \ -H 'Content-Type: application/json' \ --data '{"title":"Notes","text":"Reviewed"}' ``` A creation response, headers only: ```http HTTP/1.1 201 Created Location: /documents/notes Content-Type: application/json Cache-Control: no-store ``` `Location` points to the main resource that was created. On a 201 it's information for the client, not a redirect the browser follows. ## Key Characteristics POST is neither safe nor idempotent. Picture a connection that drops after the server has committed an order but before the response arrives. If the client sends the request again, it may create a second order. Applications can add their own deduplication, but the method doesn't give you that. POST responses are cacheable in a narrow case: they need explicit freshness information and a `Content-Location` that matches the target URI. A cache can then use that stored response for later GET or HEAD requests, never for another POST. In practice, many caches only cache GET and HEAD. If a response must never be stored, say so with `Cache-Control: no-store` instead of counting on the method to keep it private. ## Content Types For JSON, set `Content-Type: application/json`. For URL-encoded forms, let curl do the encoding: ```bash curl -i 'https://api.example.com/search' \ --data-urlencode 'q=red shoes' \ --data-urlencode 'category=books' ``` For uploads, point at a real local file and let the client generate the multipart boundary: ```bash curl -i 'https://api.example.com/uploads' \ -F 'file=@./report.pdf;type=application/pdf' \ -F 'description=Quarterly report' ``` In the browser, `FormData` does the same job. Leave `Content-Type` unset and let it write the header. A bare `multipart/form-data` you set by hand has no boundary, so the server can't parse the body. The server's body parser has to match the request's media type. In Express, JSON and URL-encoded forms each get their own middleware: ```javascript app.use(express.json({ limit: '100kb' })) app.use(express.urlencoded({ extended: false, limit: '100kb' })) ``` The [documented JSON parser limit](https://expressjs.com/en/5x/api/express/#express.json) defaults to `100kb`; this example just makes it explicit. Neither middleware handles multipart uploads, so add a multipart parser with its own limits if you accept files. A reverse proxy might reject a large upload before Express sees it, so when you get a 413, find out which layer sent it. For plain text, `--data-binary` sends a local file's contents unchanged: ```bash curl -i 'https://api.example.com/import-text' \ -H 'Content-Type: text/plain; charset=utf-8' \ --data-binary @./notes.txt ``` You could assemble a multipart body by hand, but `-F` generates matching boundary delimiters and part headers for you. In browser code, FormData does the same. ## Real-World Examples After handling an HTML form, the server can reply with a 303 pointing at a result page. The browser then loads that page with GET, instead of leaving the form POST as the page on screen: ```http HTTP/1.1 303 See Other Date: Mon, 05 Oct 2026 12:00:00 GMT Location: /orders/42 Content-Length: 0 ``` This is the Post/Redirect/Get pattern. Refreshing the result page reloads the GET instead of resubmitting the form. It only helps with refreshes, though: if two submissions already reached the server, you still have two. ### Creating a document in Express This runnable example assigns a UUID, keeps the document in process memory, and returns its URL: ```javascript const express = require('express') const { randomUUID } = require('node:crypto') const app = express() const documents = new Map() app.use(express.json({ limit: '100kb' })) app.post('/documents', (req, res) => { const { title, text } = req.body ?? {} if (typeof title !== 'string' || typeof text !== 'string') { return res.status(400).json({ error: 'title and text must be strings' }) } const id = randomUUID() const document = { id, title, text } documents.set(id, document) res.location(`/documents/${id}`).status(201).json(document) }) app.get('/documents/:id', (req, res) => { const document = documents.get(req.params.id) if (!document) return res.status(404).end() res.json(document) }) app.listen(3000) ``` Send the same JSON twice and compare the two Location values: you get two documents. That's POST working as designed. The example skips authentication and durable storage, which a real application needs. The 201 tells the client something was created, and following its Location retrieves it. ## Response Status Codes Use [201](https://howhttpworks.com/status-codes/201) when something was created, [200](https://howhttpworks.com/status-codes/200) to return a processing result, [202](https://howhttpworks.com/status-codes/202) when the work was accepted but isn't finished, and [204](https://howhttpworks.com/status-codes/204) for success with no body. A content type the endpoint doesn't support gets [415](https://howhttpworks.com/status-codes/415), and a body that's too large gets [413](https://howhttpworks.com/status-codes/413). ## POST vs GET GET retrieves a representation; POST submits content for processing. POST URLs can carry query parameters too. Moving values into the body keeps them out of the URL, but application logs and monitoring tools may still record the body. Send credentials over HTTPS and keep their values out of your logs. Neither method has unlimited size. nginx's [documented default](https://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size), for example, is `client_max_body_size 1m`, and a request body over that limit gets 413. Other proxies and applications set their own limits. ## POST vs PUT With POST, the target decides what processing means. PUT creates or replaces the state at a URL the client chooses, and it's idempotent. `POST /documents` with a server-assigned URL is a common design, but nothing requires POST to target a collection. ## Common Headers `Content-Type` describes the body you're sending, and `Accept` asks for a response format. Clients normally handle framing headers like `Content-Length` themselves, and browser Fetch won't let you set it. `Location` can point to a new resource. For a cross-origin response, JavaScript can only read it if the server sends `Access-Control-Expose-Headers: Location`. ## Getting POST right If clients retry after an uncertain failure, give them an [idempotency-key contract](https://howhttpworks.com/glossary/idempotency-key) and document it. The header on its own prevents nothing. The server has to store each key with its request and result, and stop concurrent attempts with the same key from running the action twice. In browsers, a cross-origin POST with a JSON body or an Authorization header normally triggers a [CORS preflight](https://howhttpworks.com/methods/options). The actual response needs CORS headers as well, not just the preflight. ## JavaScript Examples ```javascript const response = await fetch('/api/documents', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Notes', text: 'Reviewed' }) }) if (!response.ok) throw new Error(`POST failed: ${response.status}`) if (response.status !== 204) { const result = await response.json() // This endpoint promises JSON when it has content. console.log(result) } ``` Fetch only rejects on network failures, not HTTP error statuses. Check the status, and know what the endpoint returns, before parsing the body. ### Submitting a form with a file This handler expects an existing `