Building a Tiny HTTP Server from Scratch with net + bufio
Leeting Yan
Go’s net/http package is fantastic—but sometimes you want to see what’s really happening under the hood.
In this article, we’ll build a tiny HTTP/1.1 server from scratch, using only:
net— for TCP socketsbufio— for buffered I/O
No net/http, no handler interface, no magic.
You’ll learn:
- How HTTP/1.1 requests look on the wire
- How to parse the request line and headers
- How to build a minimal response (status line, headers, body)
- How to handle multiple connections with goroutines
- A tiny router for different paths
This is not production code—it’s a learning tool. But it’s a great way to deeply understand HTTP.
1. What HTTP Looks Like on the Wire
A simple HTTP/1.1 request:
GET /hello HTTP/1.1
Host: localhost:8080
User-Agent: curl/8.0
Accept: */*
Key parts:
- Request line:
METHOD PATH VERSION
Example:GET /hello HTTP/1.1 - Headers:
Name: Value - Blank line: marks the end of headers
- Optional body (for POST, etc.)
A minimal HTTP/1.1 response:
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 13
Hello, world!
We’ll build exactly this kind of flow manually.
2. Project Skeleton
Create a folder:
mkdir tiny-http
cd tiny-http
go mod init example.com/tiny-http
We’ll write everything in main.go for simplicity.
3. Minimal TCP Accept Loop
First, let’s just accept TCP connections and print incoming bytes.
package main
import (
"bufio"
"fmt"
"net"
)
func main() {
ln, err := net.Listen("tcp", ":8080")
if err != nil {
panic(err)
}
fmt.Println("Listening on :8080")
for {
conn, err := ln.Accept()
if err != nil {
fmt.Println("Accept error:", err)
continue
}
go handleConn(conn)
}
}
func handleConn(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
line, err := reader.ReadString('\n')
if err != nil {
fmt.Println("Read error:", err)
return
}
fmt.Println("First line from client:", line)
}
Run it:
go run .
Then in another terminal:
curl -v http://localhost:8080/
You’ll see the first request line printed.
4. Parsing the Request Line and Headers
We’ll build a tiny Request struct and parse enough of HTTP to get:
- Method
- Path
- Headers (as a map)
- Optional body (we’ll keep this simple)
4.1 Define a Simple Request Type
type Request struct {
Method string
Path string
Version string
Header map[string]string
Body []byte
}
4.2 Parse Request Using bufio.Reader
We’ll write a readRequest function.
import (
"bufio"
"errors"
"io"
"strings"
)
// readRequest reads a single HTTP/1.1 request from the connection.
func readRequest(reader *bufio.Reader) (*Request, error) {
// Read request line: e.g. "GET /hello HTTP/1.1\r\n"
line, err := reader.ReadString('\n')
if err != nil {
return nil, err
}
line = strings.TrimSpace(line)
if line == "" {
return nil, errors.New("empty request line")
}
parts := strings.Split(line, " ")
if len(parts) != 3 {
return nil, fmt.Errorf("malformed request line: %q", line)
}
req := &Request{
Method: parts[0],
Path: parts[1],
Version: parts[2],
Header: make(map[string]string),
}
// Read headers until blank line
for {
hline, err := reader.ReadString('\n')
if err != nil {
return nil, err
}
hline = strings.TrimSpace(hline)
if hline == "" {
break // end of headers
}
idx := strings.Index(hline, ":")
if idx == -1 {
continue // skip malformed header
}
name := strings.TrimSpace(hline[:idx])
value := strings.TrimSpace(hline[idx+1:])
// Simple lowercase normalization
req.Header[strings.ToLower(name)] = value
}
// Only handle small bodies via Content-Length (optional)
if cl, ok := req.Header["content-length"]; ok {
// naive conversion without robust error handling
var length int
fmt.Sscanf(cl, "%d", &length)
if length > 0 {
body := make([]byte, length)
if _, err := io.ReadFull(reader, body); err != nil {
return nil, err
}
req.Body = body
}
}
return req, nil
}
This is intentionally minimal—but already reflects how real HTTP parsing works.
5. A Tiny Response Builder
We’ll build a helper to write HTTP responses manually.
type Response struct {
StatusCode int
StatusText string
Header map[string]string
Body []byte
}
func writeResponse(w *bufio.Writer, resp *Response) error {
// Default to 200 OK if not set
if resp.StatusCode == 0 {
resp.StatusCode = 200
resp.StatusText = "OK"
}
if resp.StatusText == "" {
resp.StatusText = statusText(resp.StatusCode)
}
// Write status line
_, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", resp.StatusCode, resp.StatusText)
if err != nil {
return err
}
// Set Content-Length if not provided
if resp.Header == nil {
resp.Header = make(map[string]string)
}
if _, ok := resp.Header["Content-Length"]; !ok {
resp.Header["Content-Length"] = fmt.Sprintf("%d", len(resp.Body))
}
if _, ok := resp.Header["Content-Type"]; !ok {
resp.Header["Content-Type"] = "text/plain; charset=utf-8"
}
// Write headers
for k, v := range resp.Header {
_, err := fmt.Fprintf(w, "%s: %s\r\n", k, v)
if err != nil {
return err
}
}
// End of headers
if _, err := w.WriteString("\r\n"); err != nil {
return err
}
// Write body
if len(resp.Body) > 0 {
if _, err := w.Write(resp.Body); err != nil {
return err
}
}
return w.Flush()
}
func statusText(code int) string {
switch code {
case 200:
return "OK"
case 404:
return "Not Found"
case 500:
return "Internal Server Error"
default:
return "Status"
}
}
Note: We’re not covering chunked encoding, compression, etc.—this is a tiny HTTP server.
6. Adding a Mini Router
Let’s route different paths by hand.
We’ll implement a simple handleRequest function:
func handleRequest(req *Request) *Response {
switch {
case req.Method == "GET" && req.Path == "/":
return &Response{
StatusCode: 200,
Body: []byte("Hello from tiny HTTP server\n"),
}
case req.Method == "GET" && req.Path == "/hello":
return &Response{
StatusCode: 200,
Body: []byte("Hello, world!\n"),
}
case req.Method == "POST" && req.Path == "/echo":
return &Response{
StatusCode: 200,
Header: map[string]string{
"Content-Type": "text/plain; charset=utf-8",
},
Body: req.Body,
}
default:
return &Response{
StatusCode: 404,
Body: []byte("404 page not found\n"),
}
}
}
7. Wiring It Together: Multi-Request (Keep-Alive) Handler
Let’s now rewrite handleConn to:
- Use
bufio.Reader+bufio.Writer - Parse the request
- Route it
- Write the response
- Support multiple requests per connection (basic keep-alive)
func handleConn(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
writer := bufio.NewWriter(conn)
for {
// Try to read a request
req, err := readRequest(reader)
if err != nil {
if err == io.EOF {
fmt.Println("Client closed connection")
} else if ne, ok := err.(net.Error); ok && ne.Timeout() {
fmt.Println("Timeout:", err)
} else {
fmt.Println("Read error:", err)
}
return
}
fmt.Printf("Received %s %s\n", req.Method, req.Path)
// Generate a response
resp := handleRequest(req)
// Very naive keep-alive decision: if client says "Connection: close", we close
connectionHeader := strings.ToLower(req.Header["connection"])
if connectionHeader == "close" {
if resp.Header == nil {
resp.Header = make(map[string]string)
}
resp.Header["Connection"] = "close"
if err := writeResponse(writer, resp); err != nil {
fmt.Println("Write error:", err)
}
return
}
// Otherwise, we keep it open (HTTP/1.1 default keep-alive)
if err := writeResponse(writer, resp); err != nil {
fmt.Println("Write error:", err)
return
}
}
}
This server now supports:
- Multiple requests over a single TCP connection (keep-alive)
- Simple routing
- GET and POST
- Basic request/response parsing
8. Full Example: main.go
Here’s the complete tiny HTTP server in one file for convenience:
package main
import (
"bufio"
"errors"
"fmt"
"io"
"net"
"strings"
)
type Request struct {
Method string
Path string
Version string
Header map[string]string
Body []byte
}
type Response struct {
StatusCode int
StatusText string
Header map[string]string
Body []byte
}
func main() {
ln, err := net.Listen("tcp", ":8080")
if err != nil {
panic(err)
}
fmt.Println("Tiny HTTP server listening on :8080")
for {
conn, err := ln.Accept()
if err != nil {
fmt.Println("Accept error:", err)
continue
}
go handleConn(conn)
}
}
func handleConn(conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
writer := bufio.NewWriter(conn)
for {
req, err := readRequest(reader)
if err != nil {
if err == io.EOF {
return
}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
fmt.Println("Timeout:", err)
} else {
fmt.Println("Read error:", err)
}
return
}
fmt.Printf("Received %s %s\n", req.Method, req.Path)
resp := handleRequest(req)
connectionHeader := strings.ToLower(req.Header["connection"])
if connectionHeader == "close" {
if resp.Header == nil {
resp.Header = make(map[string]string)
}
resp.Header["Connection"] = "close"
if err := writeResponse(writer, resp); err != nil {
fmt.Println("Write error:", err)
}
return
}
if err := writeResponse(writer, resp); err != nil {
fmt.Println("Write error:", err)
return
}
}
}
func readRequest(reader *bufio.Reader) (*Request, error) {
line, err := reader.ReadString('\n')
if err != nil {
return nil, err
}
line = strings.TrimSpace(line)
if line == "" {
return nil, errors.New("empty request line")
}
parts := strings.Split(line, " ")
if len(parts) != 3 {
return nil, fmt.Errorf("malformed request line: %q", line)
}
req := &Request{
Method: parts[0],
Path: parts[1],
Version: parts[2],
Header: make(map[string]string),
}
for {
hline, err := reader.ReadString('\n')
if err != nil {
return nil, err
}
hline = strings.TrimSpace(hline)
if hline == "" {
break
}
idx := strings.Index(hline, ":")
if idx == -1 {
continue
}
name := strings.ToLower(strings.TrimSpace(hline[:idx]))
value := strings.TrimSpace(hline[idx+1:])
req.Header[name] = value
}
if cl, ok := req.Header["content-length"]; ok {
var length int
fmt.Sscanf(cl, "%d", &length)
if length > 0 {
body := make([]byte, length)
if _, err := io.ReadFull(reader, body); err != nil {
return nil, err
}
req.Body = body
}
}
return req, nil
}
func handleRequest(req *Request) *Response {
switch {
case req.Method == "GET" && req.Path == "/":
return &Response{
StatusCode: 200,
Body: []byte("Hello from tiny HTTP server\n"),
}
case req.Method == "GET" && req.Path == "/hello":
return &Response{
StatusCode: 200,
Body: []byte("Hello, world!\n"),
}
case req.Method == "POST" && req.Path == "/echo":
return &Response{
StatusCode: 200,
Header: map[string]string{
"Content-Type": "text/plain; charset=utf-8",
},
Body: req.Body,
}
default:
return &Response{
StatusCode: 404,
Body: []byte("404 page not found\n"),
}
}
}
func writeResponse(w *bufio.Writer, resp *Response) error {
if resp.StatusCode == 0 {
resp.StatusCode = 200
}
if resp.StatusText == "" {
resp.StatusText = statusText(resp.StatusCode)
}
if _, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", resp.StatusCode, resp.StatusText); err != nil {
return err
}
if resp.Header == nil {
resp.Header = make(map[string]string)
}
if _, ok := resp.Header["Content-Length"]; !ok {
resp.Header["Content-Length"] = fmt.Sprintf("%d", len(resp.Body))
}
if _, ok := resp.Header["Content-Type"]; !ok {
resp.Header["Content-Type"] = "text/plain; charset=utf-8"
}
for k, v := range resp.Header {
if _, err := fmt.Fprintf(w, "%s: %s\r\n", k, v); err != nil {
return err
}
}
if _, err := w.WriteString("\r\n"); err != nil {
return err
}
if len(resp.Body) > 0 {
if _, err := w.Write(resp.Body); err != nil {
return err
}
}
return w.Flush()
}
func statusText(code int) string {
switch code {
case 200:
return "OK"
case 404:
return "Not Found"
case 500:
return "Internal Server Error"
default:
return "Status"
}
}
9. Trying It Out
Run:
go run .
Then:
curl -v http://localhost:8080/
curl -v http://localhost:8080/hello
curl -v -X POST http://localhost:8080/echo -d "Hi tiny server"
You’ll see:
- Responses from your hand-crafted server
- Logs of parsed requests in your terminal
10. Where to Go Next
From here, you can:
- Add query string parsing
- Support HTTP/1.0 quirks
- Implement chunked encoding for responses
- Add TLS by wrapping
net.Connwithcrypto/tls - Build a mini framework that wraps this API