← Back to DevBytes

HTTP/1.1 Protocol: A Complete Reference Guide

HTTP/1.1 Protocol: A Complete Reference Guide

What Is HTTP/1.1?

HTTP/1.1 (Hypertext Transfer Protocol version 1.1) is the most widely adopted version of the HTTP protocol that powers the World Wide Web. Standardized in RFC 2616 in 1999 and later refined by RFC 7230-7235 in 2014, it defines the rules for how clients (browsers, mobile apps, curl) and servers exchange data over TCP connections. HTTP/1.1 is a text-based, request-response protocol operating at Layer 7 of the OSI model, built on top of a reliable transport layer (typically TCP).

At its core, HTTP/1.1 transmits messages in plain ASCII text following a strict structure: a start line (request or status line), a series of key-value headers separated by CRLF (\r\n), an empty line marking the end of headers, and an optional message body. This human-readable format is one reason HTTP became so successful — you can literally debug it by reading raw bytes off a socket.

Why HTTP/1.1 Matters

Even in 2025, with HTTP/2 and HTTP/3 gaining traction, HTTP/1.1 remains the lingua franca of the internet. Here's why it continues to matter:

Core Concepts: The Request-Response Cycle

Every HTTP/1.1 interaction follows the same pattern: the client opens a TCP connection, sends a request message, and waits for the server's response. With persistent connections (keep-alive), multiple request-response pairs can flow over a single TCP socket, significantly reducing latency compared to HTTP/1.0's one-request-per-connection model.

HTTP/1.1 Request Structure

An HTTP request consists of four parts:

  1. Request Line — method, request target, and protocol version
  2. Headers — zero or more Name: Value pairs
  3. Empty Line — a bare CRLF separating headers from body
  4. Body — optional payload data

Here is the canonical format:

METHOD /path/to/resource HTTP/1.1\r\n
Host: example.com\r\n
Header-Name: Header-Value\r\n
\r\n
[optional body content]

Request Methods

HTTP/1.1 defines eight methods. Each conveys a different semantic intent:

Additionally, the PATCH method (RFC 5789) is widely used in modern APIs for partial updates, though it was not in the original HTTP/1.1 specification.

HTTP/1.1 Response Structure

A response mirrors the request structure:

  1. Status Line — protocol version, status code, and reason phrase
  2. Headers — server metadata about the response
  3. Empty Line — CRLF separator
  4. Body — the actual content
HTTP/1.1 200 OK\r\n
Content-Type: text/html; charset=utf-8\r\n
Content-Length: 42\r\n
Cache-Control: public, max-age=3600\r\n
\r\n
<html><body>Hello, World!</body></html>

Status Code Classes

HTTP/1.1 status codes are three-digit numbers grouped into five classes:

Essential HTTP/1.1 Headers

Headers are the metadata backbone of HTTP. Here are the most important ones you'll encounter daily:

General Headers

Request Headers

Response Headers

Connection Management and Keep-Alive

One of HTTP/1.1's biggest improvements over 1.0 is persistent connections. In HTTP/1.0, each request required opening a new TCP connection, completing the three-way handshake, and then tearing it down with a four-way close. For a webpage with 10 images, that meant 11 separate TCP connections — each with its own slow-start phase.

HTTP/1.1 defaults to keeping the connection open after a response, allowing multiple requests to be pipelined or sent sequentially over the same socket. This is controlled by the Connection header:

GET /index.html HTTP/1.1
Host: www.example.com
Connection: keep-alive

HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 2048

[body data]

# Second request on same connection:
GET /style.css HTTP/1.1
Host: www.example.com
Connection: keep-alive

HTTP Pipelining (sending multiple requests without waiting for responses) was specified in HTTP/1.1 but proved problematic in practice — head-of-line blocking meant a slow response would stall all subsequent responses. Most browsers disabled it. HTTP/2 solved this with multiplexing.

To gracefully close a connection, a client or server sends a Connection: close header on the final message. The peer then knows to close the socket after consuming that message.

Chunked Transfer Encoding

When the server doesn't know the complete size of the response body at the time it starts sending headers (dynamic content, streaming, compression on-the-fly), it cannot send a Content-Length header. Instead, it uses chunked transfer encoding. The body is sent as a series of chunks, each prefixed with its size in hexadecimal, followed by CRLF. The end of the body is signaled by a zero-length chunk.

HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: text/plain

7\r\n
Hello, \r\n
6\r\n
World!\r\n
0\r\n
\r\n

Each chunk is self-contained: size in hex, then CRLF, then the chunk data, then CRLF. The final 0\r\n\r\n tells the client the body is complete. This allows the server to flush data progressively — the client can start processing the first chunk before the entire response is generated.

Caching and Conditional Requests

HTTP/1.1's caching model is one of the most sophisticated parts of the protocol. It saves bandwidth, reduces latency, and offloads server processing. The model involves:

Here is a complete caching negotiation example:

# First request
GET /api/users/42 HTTP/1.1
Host: api.example.com

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=60
ETag: "abc123"
Last-Modified: Tue, 15 Jan 2025 08:00:00 GMT
Date: Tue, 15 Jan 2025 09:00:00 GMT

{"id":42,"name":"Alice"}

# 65 seconds later (cache is now stale)
GET /api/users/42 HTTP/1.1
Host: api.example.com
If-None-Match: "abc123"
If-Modified-Since: Tue, 15 Jan 2025 08:00:00 GMT

HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=60
ETag: "abc123"
Date: Tue, 15 Jan 2025 09:01:05 GMT

[no body — client uses its cached copy]

Content Negotiation

HTTP/1.1 allows clients and servers to negotiate the best representation of a resource. The client sends Accept* headers describing its preferences, and the server examines them to select (or dynamically generate) the appropriate variant. The response includes a Vary header telling caches which request headers were used for selection.

GET /document HTTP/1.1
Host: www.example.com
Accept: text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8
Accept-Encoding: gzip, deflate, br;q=0.9
Accept-Language: en-US, en;q=0.9, fr;q=0.7

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Language: en-US
Vary: Accept-Encoding, Accept-Language, Accept

[gzip-compressed HTML body]

The q parameter (quality value, 0 to 1) indicates relative preference. The Vary header is crucial: it prevents a cache from serving a gzipped English HTML page to a client that requested uncompressed French JSON.

The Host Header and Virtual Hosting

The Host header is the single most important addition in HTTP/1.1. It is mandatory — a request without it must be rejected with 400 Bad Request. Before HTTP/1.1, one IP address could only serve one website because the server had no way to know which domain the client intended. With the Host header, a single server (or reverse proxy like nginx) can host thousands of websites on one IP, dispatching requests based on the requested domain name.

GET / HTTP/1.1
Host: my-blog.example.com

GET / HTTP/1.1
Host: shop.example.com

# Both arrive at the same IP, but the server routes them differently
# based on the Host header.

Practical Code Examples

Example 1: Raw HTTP Client in Python Using Sockets

This example demonstrates the protocol at the lowest level — opening a TCP socket, crafting a valid HTTP/1.1 request by hand, and parsing the response. No HTTP library is used; you see the exact bytes sent and received.

import socket

def raw_http_get(host, port, path):
    """Send a minimal HTTP/1.1 GET request and print the raw response."""
    # Establish TCP connection
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect((host, port))
    
    # Craft the HTTP/1.1 request manually
    # Note: Each line ends with \r\n, and headers end with a blank \r\n line
    request = (
        f"GET {path} HTTP/1.1\r\n"
        f"Host: {host}\r\n"
        "User-Agent: RawSocketClient/1.0\r\n"
        "Accept: text/html,application/json;q=0.9,*/*;q=0.8\r\n"
        "Accept-Encoding: gzip\r\n"
        "Connection: close\r\n"
        "\r\n"
    )
    
    # Send the request
    sock.sendall(request.encode('utf-8'))
    
    # Receive the response in chunks
    response_data = b""
    while True:
        chunk = sock.recv(4096)
        if not chunk:
            break
        response_data += chunk
    
    sock.close()
    
    # Split headers from body at the double-CRLF boundary
    raw_text = response_data.decode('utf-8', errors='replace')
    header_section, _, body = raw_text.partition("\r\n\r\n")
    
    print("=== STATUS LINE + HEADERS ===")
    print(header_section)
    print("\n=== BODY (first 500 chars) ===")
    print(body[:500] if len(body) > 500 else body)
    
    return response_data

# Usage
raw_http_get("example.com", 80, "/")

Example 2: Minimal HTTP/1.1 Server in Node.js

This server parses raw HTTP/1.1 requests from a TCP socket, demonstrates header parsing, chunked response encoding, and proper status code handling. It supports both GET and POST, validates the Host header, and handles conditional requests with ETags.

const net = require('net');

const SERVER_PORT = 8080;

// In-memory resource store with ETags for caching demonstration
const resources = {
  '/hello': {
    data: JSON.stringify({ message: 'Hello, HTTP/1.1 world!' }),
    contentType: 'application/json',
    etag: '"v1-hello-abc"',
    lastModified: 'Wed, 21 Oct 2025 07:28:00 GMT',
  },
  '/time': {
    data: null, // dynamic — always fresh
    contentType: 'text/plain',
    etag: null, // no caching for dynamic endpoints
    lastModified: null,
  },
};

function parseRequest(rawData) {
  const text = rawData.toString('utf-8');
  const headerEnd = text.indexOf('\r\n\r\n');
  if (headerEnd === -1) return null; // incomplete request

  const headerBlock = text.substring(0, headerEnd);
  const bodyRaw = text.substring(headerEnd + 4);

  const lines = headerBlock.split('\r\n');
  const requestLine = lines[0];
  const [method, path, protocol] = requestLine.split(' ');

  const headers = {};
  for (let i = 1; i < lines.length; i++) {
    const colonIndex = lines[i].indexOf(':');
    if (colonIndex > 0) {
      const key = lines[i].substring(0, colonIndex).trim().toLowerCase();
      const value = lines[i].substring(colonIndex + 1).trim();
      headers[key] = value;
    }
  }

  // Parse Content-Length body if present
  let body = '';
  const contentLength = parseInt(headers['content-length'], 10);
  if (contentLength && contentLength > 0) {
    body = bodyRaw.substring(0, contentLength);
  }

  return { method, path, protocol, headers, body };
}

function buildResponse(statusCode, statusText, headers, body) {
  let response = `HTTP/1.1 ${statusCode} ${statusText}\r\n`;
  
  // Always include Date header (RFC 7231 requirement for origin servers)
  response += `Date: ${new Date().toUTCString()}\r\n`;
  
  for (const [key, value] of Object.entries(headers)) {
    response += `${key}: ${value}\r\n`;
  }
  
  response += '\r\n';
  
  if (body) {
    response += body;
  }
  
  return response;
}

const server = net.createServer((socket) => {
  let rawData = '';

  socket.on('data', (chunk) => {
    rawData += chunk.toString('utf-8');
    
    const parsed = parseRequest(rawData);
    if (!parsed) return; // wait for more data

    const { method, path, headers, body } = parsed;
    
    // Validate mandatory Host header (RFC 7230 Section 5.4)
    if (!headers['host']) {
      const resp = buildResponse(400, 'Bad Request', {
        'Content-Type': 'text/plain',
        'Connection': 'close',
      }, 'Missing Host header — required by HTTP/1.1');
      socket.write(resp);
      socket.end();
      return;
    }

    console.log(`${method} ${path} from Host: ${headers['host']}`);

    // Route the request
    if (method === 'GET' && path === '/time') {
      // Dynamic endpoint — no caching, use chunked encoding
      const now = new Date().toISOString();
      const responseHeaders = {
        'Content-Type': 'text/plain',
        'Transfer-Encoding': 'chunked',
        'Cache-Control': 'no-store',
      };
      
      let chunkedResponse = `HTTP/1.1 200 OK\r\n`;
      chunkedResponse += `Date: ${new Date().toUTCString()}\r\n`;
      for (const [k, v] of Object.entries(responseHeaders)) {
        chunkedResponse += `${k}: ${v}\r\n`;
      }
      chunkedResponse += '\r\n';
      // Chunked body: send hex length, then data, then CRLF
      const bodyText = `Server time: ${now}\n`;
      const hexLength = bodyText.length.toString(16);
      chunkedResponse += `${hexLength}\r\n${bodyText}\r\n`;
      chunkedResponse += `0\r\n\r\n`; // terminating chunk
      
      socket.write(chunkedResponse);
      socket.end();
      
    } else if (method === 'GET' && resources[path]) {
      const resource = resources[path];
      const responseHeaders = {
        'Content-Type': resource.contentType,
        'Cache-Control': 'public, max-age=30',
        'Connection': 'keep-alive',
      };
      
      // Handle conditional request with ETag
      if (resource.etag && headers['if-none-match'] === resource.etag) {
        const resp = buildResponse(304, 'Not Modified', {
          'ETag': resource.etag,
          'Cache-Control': 'public, max-age=30',
        }, '');
        socket.write(resp);
        return;
      }
      
      if (resource.etag) responseHeaders['ETag'] = resource.etag;
      if (resource.lastModified) responseHeaders['Last-Modified'] = resource.lastModified;
      responseHeaders['Content-Length'] = resource.data.length.toString();
      
      const resp = buildResponse(200, 'OK', responseHeaders, resource.data);
      socket.write(resp);
      
    } else if (method === 'POST' && path === '/echo') {
      // Echo back the posted body with CORS header for browser access
      const responseHeaders = {
        'Content-Type': 'application/json',
        'Content-Length': body.length.toString(),
        'Access-Control-Allow-Origin': '*',
        'Connection': 'keep-alive',
      };
      const resp = buildResponse(201, 'Created', responseHeaders, body);
      socket.write(resp);
      
    } else if (method === 'OPTIONS') {
      // CORS preflight or method discovery
      const responseHeaders = {
        'Allow': 'GET, POST, OPTIONS',
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type',
        'Content-Length': '0',
      };
      const resp = buildResponse(204, 'No Content', responseHeaders, '');
      socket.write(resp);
      
    } else {
      const responseHeaders = {
        'Content-Type': 'text/plain',
        'Content-Length': '9',
        'Connection': 'close',
      };
      const resp = buildResponse(404, 'Not Found', responseHeaders, 'Not Found');
      socket.write(resp);
      socket.end();
    }
    
    rawData = ''; // reset for potential keep-alive reuse
  });

  socket.on('end', () => {
    console.log('Client disconnected');
  });

  socket.on('error', (err) => {
    console.error('Socket error:', err.message);
  });
});

server.listen(SERVER_PORT, () => {
  console.log(`HTTP/1.1 server listening on port ${SERVER_PORT}`);
  console.log('Test with: curl -v http://localhost:8080/hello');
  console.log('Or conditional: curl -v -H "If-None-Match: \\"v1-hello-abc\\"" http://localhost:8080/hello');
});

Example 3: Complete cURL Debugging Session

cURL's -v (verbose) flag exposes the raw HTTP/1.1 conversation. This is the single most valuable debugging technique for HTTP APIs.

# Verbose GET with conditional headers
$ curl -v -H "If-None-Match: \"abc123\"" https://api.example.com/users/42

* Connected to api.example.com (1.2.3.4) port 443
> GET /users/42 HTTP/1.1
> Host: api.example.com
> User-Agent: curl/8.5.0
> Accept: */*
> If-None-Match: "abc123"
>
< HTTP/1.1 304 Not Modified
< Date: Tue, 15 Jan 2025 09:01:05 GMT
< ETag: "abc123"
< Cache-Control: public, max-age=60
< Connection: keep-alive
<
* Connection #0 to host api.example.com left intact

# POST with JSON body, showing Content-Type negotiation
$ curl -v -X POST \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/plain;q=0.9" \
  -d '{"name":"Bob","email":"bob@example.com"}' \
  https://api.example.com/users

> POST /users HTTP/1.1
> Host: api.example.com
> Content-Type: application/json
> Accept: application/json, text/plain;q=0.9
> Content-Length: 45
>
< HTTP/1.1 201 Created
< Location: /users/123
< Content-Type: application/json
< Content-Length: 52
<
{"id":123,"name":"Bob","email":"bob@example.com"}

Best Practices for HTTP/1.1 Development

Common Pitfalls and How to Avoid Them