DEV Community

Cover image for HTTP Under the Hood: HTTP/0.9
Jorge Massih
Jorge Massih

Posted on

HTTP Under the Hood: HTTP/0.9

The beginning is the most important part of any work. — Plato

Do you remember Tim Berners-Lee and his idea of connecting information through hypertext, which eventually became the Web? Between 1990 and 1991, he created some essential components to achieve that:

  1. The first iteration of HTTP, the main topic in this article
  2. The first hypertext markup language, better known as HTML
  3. The URL/URI definition to identify resources. Even though it was formalized in 1994.
  4. The first web browser, or HTTP client: WorldWideWeb browser
  5. The first HTTP server: CERN httpd, also known as W3C httpd

With these pieces in place, the Web was almost ready to start working as Berners-Lee had imagined.

HTTP/0.9

This was the first implemented version of HTTP. Actually, it didn't even have a version number at the time. The version number 0.9 was given later in 1992 in a note published by Berners-Lee:

"I propose 0.9 as the number of the current version."

If you take a careful look at the first HTTP specification document, you will notice that it looks very informal compared with the HTTP specifications that came later.

It was also the simplest possible form of HTTP: open a TCP connection, ask for a document, receive its contents, and close the connection.

Something I found interesting while researching HTTP/0.9 is that port 80 was not always the default HTTP port. The original HTTP implementation says:

If the port number is not specified, 80 is always assumed for HTTP.

However, an archived January 1992 copy of Berners-Lee's protocol notes shows that, during development, HTTP was still using port 2784 by default:

During development, the default HTTP TCP port number is 2784 -- this will change when an official port number is allocated.

Request

In the context of HTTP/0.9, a "request" is a message sent by the client to the HTTP server asking it to return a resource.

We will see in later articles of this series how this definition evolved from "asking it to return a resource" to "asking it to return a resource or perform an action".

As per the original specification, a raw request would look like:

GET /index.html
Enter fullscreen mode Exit fullscreen mode

The request line was terminated by a CRLF (\r\n). However, the specification also said that the CR was not mandatory, and a well-implemented server must be able to handle that missing piece:

"The client sends a document request consisting of a line of ASCII characters terminated by a CR LF (carriage return, line feed) pair. A well-behaved server will not require the carriage return character."

Some important things to notice:

  • The only implemented method was the GET method.
  • It had no header specification.
  • There was no version number in the request. The client simply sent GET followed by the resource path. Anything added after the path was either ignored or handled according to the fuller HTTP specification.

Response

In the context of HTTP/0.9, the response was even more limited than the request. The server could only return an HTML document containing either the requested content or a human-readable error. No headers, no status codes. Just HTML.

Obviously, this represents a big issue because one of the characteristics of a good system is to be capable of outputting fully deterministic results. From the protocol alone, the client cannot know if the request succeeded or failed.

Hands-On: An implementation of HTTP/0.9

One way I know that I really understand something is when I can implement it myself. So, for this article, I decided to implement HTTP/0.9 by hand, both on the client and server sides.

It's worth clarifying this implementation only covers what we need for this article. It is not designed for production use.

Before starting, I always like to create diagrams about things—when possible. The diagram below establishes a clear communication flow between two entities involved in this example, the client and the server.
HTTP/0.9 Communication flow

As you can see, the flow is basic and matches what we discussed in the previous article about HTTP working over a TCP connection. Please don't hesitate to go back to that article if you need to refresh your memory.

The code examples in this article, and probably the next ones, will be written in Go. I will also keep comments inside the code to make the behavior easier to follow.

Server implementation

For this simplified implementation, we are going to allow only 2 possible routes: / and /index.html. However, a production-grade implementation must have a reliable mechanism to configure dynamic routes.

const (
    portNumber        = 80
    errorFilePath     = "./error.html"
    inactivityTimeout = 15 * time.Second
)

var crlf []byte = []byte{'\r', '\n'}

// main starts the HTTP/0.9 server and accepts connections.
func main() {
    // HTTP uses port 80 when no other port is given.
    ln, err := net.Listen("tcp", fmt.Sprintf(":%d", portNumber))
    if err != nil {
        log.Fatalf("failed to listen on port %d: %v", portNumber, err)
    }
    defer func() {
        if err := ln.Close(); err != nil {
            log.Printf("[err] failed to close listener: %v", err)
        }
    }()

    log.Print("[info] starting HTTP server")
    log.Print("[info] serving HTTP/0.9")

    // Map document addresses to HTML files.
    routes := map[string]string{
        "/":           "./index.html",
        "/index.html": "./index.html",
    }

    for {
        // The server accepts a TCP connection from the client.
        conn, err := ln.Accept()
        if err != nil {
            log.Printf("[err] failed to accept the connection: %v", err)
            continue
        }

        go handleConn(conn, routes)
    }
}
Enter fullscreen mode Exit fullscreen mode

The approach taken in the code above is simple:

  1. It starts listening forever—until stopped—on a specified port using the TCP protocol.
  2. For each accepted connection, it starts a thread of execution, allowing multiple connections to be handled concurrently. This can also be made in a synchronous way, but in practice, it may result in poor performance.

The handleConn function shows more clearly how each request is handled. It parses the request, checks if the requested route is allowed, and sends the corresponding HTML response as the specification indicates:

"Successful responses contain HTML as a raw byte stream."

// handleConn reads one request and sends one response.
func handleConn(conn net.Conn, allowedRoutes map[string]string) {
    // The server closes the connection after sending the document.
    defer func() {
        if err := conn.Close(); err != nil && !isClientAbort(err) {
            log.Printf("[err] failed to close connection: %v", err)
        }
    }()

    if allowedRoutes == nil {
        log.Print("[warn] empty whitelist of routes")
    }

    // The server may close an inactive connection after about 15 seconds.
    if err := conn.SetReadDeadline(time.Now().Add(inactivityTimeout)); err != nil {
        log.Printf("[err] failed to set read deadline: %v", err)
        return
    }

    // A request ends with LF. The preceding CR is optional.
    reader := bufio.NewReader(conn)
    rawRequest, err := reader.ReadString('\n')
    if err != nil {
        if errors.Is(err, os.ErrDeadlineExceeded) {
            log.Print("[warn] closing inactive connection")
            return
        }
        log.Printf("[err] unable to read request: %v", err)
        writeErrResponse(conn)
        return
    }

    method, route, err := parseRawReq(rawRequest)
    if err != nil {
        log.Printf("[err] unable to parse request: %v", err)
        // HTTP/0.9 sends errors as human-readable HTML.
        writeErrResponse(conn)
        return
    }

    if filePath, ok := allowedRoutes[route]; ok {
        log.Printf("[debug] handling request %s %s", method, route)

        // Tries to open the file.
        if file, err := loadFile(filePath); err == nil {
            writeResponse(conn, file)
            return
        } else {
            log.Printf("[err] unable to load file for response: %v", err)
        }
    } else {
        log.Printf("[err] invalid route: %s", route)
    }

    writeErrResponse(conn)
}
Enter fullscreen mode Exit fullscreen mode

As mentioned before, HTTP/0.9 has no status codes or response headers. So when something goes wrong, the server returns a human-readable HTML error page.

"Error responses are supplied in human readable text in HTML syntax. There is no way to distinguish an error response from a satisfactory response except for the content of the text."

I also added a 15-second read deadline so a client cannot open a connection and leave the server waiting forever for a request.

"The server may impose a timeout of the order of 15 seconds on inactivity."

Client implementation

The main function mostly handles the CLI input: the host, the port, and the document path. Pretty dull code. Then it calls the function sendRequest and prints the returned response.

// main reads a request from an argument or stdin.
func main() {
    // Take host and port, plus an optional document address.
    // 1st: host
    // 2nd: port
    // 3rd: document address (optional)
    args := os.Args[1:]

    if len(args) < 2 {
        log.Print("error: missing argument")
        os.Exit(2)
    }

    // With two arguments, forward stdin unchanged like nc.
    var request io.Reader = os.Stdin
    if len(args) >= 3 {
        // A request is "GET", a space, the document address, and CRLF.
        request = strings.NewReader(fmt.Sprintf("GET %s\r\n", args[2]))
    }

    // An HTTP/0.9 client requests one document at a time.
    bytesSent, response, err := sendRequest(args[0], args[1], request)
    if err != nil {
        log.Printf("failed to make request: %v", err)
        os.Exit(1)
    }

    log.Printf("[debug] sent %d bytes", bytesSent)
    fmt.Print(response)
}
Enter fullscreen mode Exit fullscreen mode

The sendRequest function is more interesting. It opens a TCP connection, copies the request bytes into it, appends the CRLF bytes, and then reads the response until the server closes the connection.

// sendRequest fetches one document over HTTP/0.9.
func sendRequest(host, port string, request io.Reader) (int64, string, error) {
    var response []byte
    var bytesSent int64

    address := net.JoinHostPort(host, port)

    // The client opens a TCP connection to the given host and port.
    conn, err := net.DialTimeout("tcp", address, 20*time.Second)
    if err != nil {
        return bytesSent, "", fmt.Errorf(
            "unable to establish a connection with %s: %w",
            address,
            err,
        )
    }
    defer func() {
        if err := conn.Close(); err != nil {
            log.Printf("[warn] failed to close connection: %v", err)
        }
    }()

    // Stream the request into the TCP connection.
    bytesSent, err = io.Copy(conn, request)
    if err != nil {
        return bytesSent, "", fmt.Errorf("failed to send request: %w", err)
    }

    // The response ends when the server closes the connection.
    response, err = io.ReadAll(conn)
    if err != nil {
        return bytesSent, "", fmt.Errorf("failed to read the response: %w", err)
    }

    return bytesSent, string(response), nil
}
Enter fullscreen mode Exit fullscreen mode

Notice that we don't need Content-Length or any other marker here. io.ReadAll finishes when the server closes the TCP connection, and in HTTP/0.9 that is how the client knows the response is complete.

With the server running, a request to a valid route looks like this:

$ echo "GET /index.html" | go run main.go localhost 80
2026/08/20 17:40:14 [debug] sent 16 bytes
<!doctype html>
<html>
  <head>
    <title>HTTP Under the Hood series</title>
  </head>
  <body>
    hello world!!
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

But if we request a route that is not in the whitelist, we still receive an HTML document. This time it contains a human-readable error message:

$ echo "GET /non/listed/path.html" | go run main.go localhost 80
2026/08/20 17:41:42 [debug] sent 21 bytes
<!doctype html>
<html>
  <head>
    <title>HTTP Under the Hood series</title>
  </head>
  <body>
    An error has occurred with your request :(
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

You can find the complete implementation in my GitHub repository.

Conclusion

At this point, we have implemented both sides of HTTP/0.9 and seen how small the protocol actually was: the client opened a TCP connection, asked for a document, received some bytes, and the server closed the connection.

But our last example also exposed one of its biggest limitations. Our client received bytes in both cases: when the document existed and when it didn't. From the protocol itself, there was no way to know if the request succeeded or failed. There were no status codes, no response headers, and no machine-readable information describing what happened.

For the first version of the Web, this simplicity was enough. But as the Web started to grow, these limitations became harder to ignore. And this is where HTTP starts getting more interesting.

In the next article, we will see how the protocol evolved to solve some of these problems and how HTTP started becoming closer to the protocol we recognize today.

Top comments (0)