Go HTTP server

A fully custom HTTP server implemented in Go, supporting request parsing, partial parsing, static file serving, and dynamic endpoint handling.

Programming
Protocols
Code
Banner image

What are we creating?

We’re creating a fully customized HTTP server in Go — no external libraries, just pure Go. You might be wondering, why reinvent the wheel when net/http already exists? Well, if you’re a nerd like me and love understanding how things work under the hood, this is the perfect blog for you. For example, you’ll see how to handle partial parsing of requests.

All you need before diving in:

  • Basic Go programming knowledge (you don’t have to be a pro).
  • A general idea of how TCP and HTTP work, like what is header and how they work and etc.

What we will build in this project?

  • Partial HTTP parsing – handle request line, headers, and body manually, including incomplete requests over TCP.
  • Routing requests – match incoming requests by method and path, support dynamic URL parameters like /users/{id}.
  • Middleware support – apply functions to requests before reaching the final handler, allow chaining similar to Express.js.
  • Custom response handling – send status codes (200, 400, 404, 500), write headers and body, support Keep-Alive connections.
  • Server lifecycle management – listen on TCP ports, manage connection timeouts, keep alive, and support graceful shutdown.

File Structure

This isn't the best file structure. feel free to adjust it to your needs. Here's the current layout.

cmd/main.go

  • Entry point of the application.
  • Usually contains the code to start the server.

internals/http/

  • Low-level HTTP handling utilities.
    • common.go → Shared HTTP functions and constants.
    • headers.go → Functions and types for handling HTTP headers.
    • request.go → Parsing and handling HTTP requests.
    • response.go → Constructing and sending HTTP responses.

internals/server/

  • Core server logic and routing.
    • middleware.go → Middleware logic (logging, authentication, etc.).
    • route.go → Route definitions and parameter parsing.
    • server.go → Main server setup, listener, and request handling.

internals/type/

  • Definitions of constants, enums, and types used throughout the server.
    • contentType.go → MIME types.
    • error.go → Custom error types.
    • Method.go → HTTP methods (GET, POST, etc.).
    • parseState.go → Internal parsing states for request handling.
    • statusCode.go → HTTP status codes (200, 404, 500, etc.).
    • version.go → HTTP versions (1.0, 1.1, etc.).

internals/utils/url.go

  • Utility functions for URL parsing and manipulation.

static/

  • Directory for static files like HTML, CSS, JS, and images.
project structure
1my-server/ 2├─ cmd/ 3│ └─ main.go 4├─ internals/ 5│ ├─ http/ 6│ │ ├─ common.go 7│ │ ├─ headers.go 8│ │ ├─ request.go 9│ │ └─ response.go 10│ ├─ server/ 11│ │ ├─ middleware.go 12│ │ ├─ route.go 13│ │ └─ server.go 14│ ├─ type/ 15│ │ ├─ contentType.go 16│ │ ├─ error.go 17│ │ ├─ Method.go 18│ │ ├─ parseState.go 19│ │ ├─ statusCode.go 20│ │ └─ version.go 21│ └─ utils/ 22│ └─ url.go 23└─ static/ 24 25

Requests

HTTP over TCP

Let’s start with parsing the request. This can be the trickiest part at least it was for me. You might be wondering why. The thing is, in TCP, you might receive only part of the request in the first packet. For example, you could get just the method and path, then the next packet might contain headers, and maybe only part of the body.

The good news is, we don’t need to worry about putting everything in order TCP already handles that for us using sequence numbers. That itself is an interesting topic for another time.

For example, the first packet might contain:

GET /API

Looks incomplete, right? Then the next packet could bring:

HTTP/1.0\r\n

Once we combine them, we start to get the full request. From there, we can continue parsing headers, body, and everything else we need. full request will be GET /API HTTP1.0\r\n with all in mind let's start doing this.

Parsing

RequestLine holds the HTTP types.Method just method enum, path, and version. Request combines the request line, headers, body, parsing state like are we currently parsing Header or body or what this will make since in a mini, and URL parameters into one structure. It represents the full HTTP request received by the server.

request.go
1type RequestLine struct { 2 Method types.Method 3 Path string 4 Version types.Version 5} 6type Request struct { 7 RequestLine RequestLine 8 Body []byte 9 Headers Header 10 status types.ParseState 11 Params url.Params 12} 13func NewRequestParser() *Request { 14 return &Request{ 15 status: types.StateRequestLine, 16 Body: nil, 17 Headers: make(Header), 18 } 19}

Don’t get scared, this function is actually pretty simple. First, we create a buffer of fixed size (like 1024 bytes) and initialize our request structure. We also set up buffLen, which keeps track of how much of the buffer is already filled.

Then we enter a loop that keeps reading from the client until the request is fully parsed (req.status == StateDone).

bytesRead, err := reader.Read(buff[buffLen:]).This line reads data from the client and writes it into the buffer starting from buffLen. After reading, we update buffLen so we know how much data is in the buffer.
Next, we call the parser:

consumed, err := req.parsing(buff[:buffLen]).It will try to parse as much as it can. consumed tells us how many bytes were used. If we didn’t finish parsing or got an EOF, we handle that.

Finally, any leftover data in the buffer (that wasn’t consumed) is moved to the front so we can reuse the same buffer for the next read:

copy(buff, buff[consumed:buffLen])
buffLen -= consumed

magine your buffer has "GET / HTTP/1.1\r\nHost: loca" and you just parsed "GET / HTTP/1.1\r\n" (16 bytes). That leaves "Host: loca" unprocessed.

By moving the leftover to the front and reducing the buffer length, the next read will append new data right after "Host: loca". This way, you don’t lose any unprocessed bytes and can keep using the same buffer efficiently.

We repeat this until the request is done. At the end, we return the fully parsed request. read → parse → move leftovers → repeat until done.

request.go
1func ParseRequest(reader io.Reader) (*Request, error) { 2 buff := make([]byte, DefaultBufferSize) 3 req := NewRequestParser() 4 buffLen := 0 5 for req.status != types.StateDone { 6 7 bytesRead, err := reader.Read(buff[buffLen:]) 8 if err != nil { 9 return nil, err 10 } 11 buffLen += bytesRead 12 13 consumed, err := req.parsing(buff[:buffLen]) 14 if err != nil { 15 // if the error EOF. change the status to Done 16 if errors.Is(err, io.EOF) { 17 req.status = types.StateDone 18 break 19 } 20 return nil, err 21 } 22 23 // Move leftover data (buff[consumed:buffLen]) to the front (index 0) 24 // so we can reuse the buffer for the next read. 25 copy(buff, buff[consumed:buffLen]) 26 buffLen -= consumed 27 } 28 29 return req, nil 30 31} 32

Its job is to read the request step by step: first the request line, then headers, and finally the body. We also keep track of how many bytes we’ve “used” from the buffer with consumed.

We start a loop that keeps checking the current parsing state:

  1. Request Line (StateRequestLine)
    • Calls parseRequestLine on the current data.
    • If it successfully parses something, we increase consumed and move to headers.
    • If there’s nothing to parse yet, we just return and wait for more data.
  2. Headers (StateHeader)
    • Calls parseRequestHeader.
    • Same logic: increase consumed if we parsed something, then move to body.
  3. Body (StateBody)
    • Checks Content-Length to know how many bytes to read.
    • Appends as much as possible from the current buffer to req.Body.

The key idea: read a bit → parse a bit → keep track of consumed → repeat until done. This way we can handle requests that arrive in multiple packets.

request.go
1func (req *Request) parsing(data []byte) (int, error) { 2 consumed := 0 3 for { 4 currentData := data[consumed:] 5 switch req.status { 6 case types.StateRequestLine: 7 reqLen, err := req.parseRequestLine(currentData) 8 9 if err != nil { 10 return consumed, err 11 } 12 if reqLen == 0 { 13 return consumed, nil 14 } 15 16 consumed += reqLen 17 req.status = types.StateHeader 18 case types.StateHeader: 19 headerLen, err := req.parseRequestHeader(currentData) 20 if err != nil { 21 return consumed, err 22 } 23 if headerLen == 0 { 24 return consumed, nil 25 } 26 consumed += headerLen 27 req.status = types.StateBody 28 case types.StateBody: 29 length, err := req.Headers.Get("Content-Length") 30 if err != nil { 31 req.status = types.StateDone 32 } 33 n, err := strconv.Atoi(length) 34 if err != nil || n == 0 { 35 req.status = types.StateDone 36 } 37 readLength := min(n-len(req.Body), len(currentData)) 38 if readLength == 0 { 39 // We've read everything available so far, but not the full body 40 // Return control so caller can read more data into buffer 41 return consumed, nil 42 } 43 req.Body = append(req.Body, currentData...) 44 consumed += readLength 45 if len(req.Body) >= n { 46 req.status = types.StateDone 47 } 48 case types.StateDone: 49 return consumed, nil 50 } 51 } 52}

Let’s focus on parsing the request line first. The request line is the very first line of an HTTP request and looks like METHOD PATH VERSION, ending with \r\n.

The first thing we do is check if the data we have contains \r\n. If it doesn’t, that means we haven’t received the full line yet, so we just return 0 and no error. This tells the parser: “keep reading, we’re not done yet.”

Once we find the \r\n, we know we have the full line, so we can start processing it. We split the line by spaces to get three parts: the method, the path, and the version. If there aren’t exactly three parts, it’s an invalid request line and we return an error.

Next, we parse the method and version using helper functions, and save the method, path, and version into the request structure. Finally, we return the number of bytes we consumed (the length of the line plus the \r\n) so the parser knows how much of the buffer was used.

request.go
1 2func (req *Request) parseRequestLine(data []byte) (int, error) { 3 // method path version \r\n 4 idx := bytes.Index(data, []byte(SEPARATOR)) 5 // we did not get the full line 6 // so do not return error 7 if idx == -1 { 8 return 0, nil 9 } 10 parts := bytes.Split(data[:idx], []byte(" ")) 11 if len(parts) != 3 { 12 return 0, fmt.Errorf("%w: expected 3 parts, got %d", ErrInvalidRequestLine, len(parts)) 13 } 14 method, err := types.ParseMethod(parts[0]) 15 if err != nil { 16 return 0, err 17 } 18 version, err := types.ParseVersion(parts[2]) 19 if err != nil { 20 return 0, err 21 } 22 req.RequestLine.Method = method 23 req.RequestLine.Version = version 24 req.RequestLine.Path = string(parts[1]) 25 return idx + len(SEPARATOR), nil 26}

With this mechanism, we can receive requests partially. Let’s test it by sending characters one by one to see if our server handles it. Real TCP packets aren’t exactly like this, but it’s good for testing.

sending request.sh
1 cat sending.sh 2#!/bin/bash 3 4text=$'GET / HTTP/1.1\r\nHost: localhost:8080\r\nConnection: close\r\n\r\n' 5 6for ((i=0; i<${#text}; i++)); do 7 printf "%s" "${text:$i:1}" 8 sleep 0.1 9done | nc localhost 8080

Header

Header parsing is simple. Each header line looks like key: value\r\n. We can store them in a map so it’s easy to look up values by key:

type Header map[string]string This way, Content-Length, Host, or any header can be accessed quickly. so we can create some helper to help us like

  1. func (h *Header) Delete(key string) error
  2. func (h Header) ForEach(f func(key, value string))
  3. func (h *Header) Get(key string) (string, error)
  4. func (h *Header) Set(key, value string) error
  5. etc.

Response

the response structure slimier to the request but with some differences. the only different as we can see the status line the Header and body structure are the exact same.

ResponseWriter is the structure we use to build and send HTTP responses.

  • write: where the response bytes go (usually the client connection).
  • idleTimeout: how long to wait before closing the connection if nothing happens. here i'm using it for keep alive it can be configured.
  • isKeepAlive: tells if the connection should stay open after sending the response.
  • Version: HTTP version for this response (like 1.0 or 1.1). this just enum values in the types folder
  • Status: the HTTP status code (200, 404, etc.). enum values in the types.
  • Headers: pointer to a map of headers (key: value) to send with the response.

Basically, this struct holds everything needed to construct and send a proper HTTP response to the client.

respinse.go
1type ResponseWriter struct { 2 write io.Writer 3 idleTimeout time.Duration 4 isKeepAlive bool 5 Version types.Version 6 Status types.StatusCode 7 Headers *Header 8} 9func NewResponseWriter(w io.Writer, idleTimeout time.Duration) *ResponseWriter { 10 return &ResponseWriter{ 11 write: w, 12 idleTimeout: idleTimeout, 13 isKeepAlive: false, 14 Version: types.HTTP1_1, 15 Status: types.OK, 16 Headers: NewHeader(), 17 } 18} 19

I split sending the status line, headers, and body into separate functions so anyone creating an endpoint can easily customize the response, like changing headers, status, or body, without touching the core logic. You can set headers manually, or use the helper function I added to handle everything automatically, like func (w *ResponseWriter) SendResponse(body []byte) *types.RouteError, which takes the body and sets headers and sends the response dynamically.

let's start by sending the status line,function sends the status line of the HTTP response, which is the first line like HTTP/1.1 200 OK.

  1. It gets the current status code from the ResponseWriter (w.Status).
  2. It looks up the text for that status code (like OK for 200) from types.StatusText.
  3. If the code is unknown, it returns an error.
  4. Finally, it writes the full status line to the client in the format: HTTP version, status code, status text, followed by \r\n.
response.go
1func (w *ResponseWriter) WriteStatusLine() error { 2 code := w.Status 3 text, ok := types.StatusText[code] 4 if !ok { 5 return ErrUnknownStatusCode 6 } 7 _, err := fmt.Fprintf(w.write, "%s %d %s\r\n", w.Version.String(), code, text) 8 return err 9} 10

The WriteHeader function sends all the HTTP headers to the client. It goes through each key-value pair in the Headers map and writes them as Key: Value\r\n. After sending all headers, it adds an extra \r\n to signal the end of the header section. This tells the browser or client things like content type, content length, or connection info.

response.go
1func (w *ResponseWriter) WriteHeader() error { 2 for key, value := range *w.Headers { 3 if _, err := fmt.Fprintf(w.write, "%s: %s\r\n", key, value); err != nil { 4 return err 5 } 6 } 7 _, err := fmt.Fprint(w.write, "\r\n") 8 return err 9} 10

The WriteBody function is the simplest part: it just sends the actual response body to the client. Whatever bytes you pass to it HTML, JSON, text, or even an image get written directly to the connection.

response.go
1 2func (w *ResponseWriter) WriteBody(data []byte) error { 3 _, err := w.write.Write(data) 4 return err 5}

With all of that, let’s create two more functions to make sending responses easier. The first function is SetDefaultHeaders. Every time we send a response, we would otherwise need to manually set headers like Content-Length, Content-Type, Date, and possibly others. Doing this repeatedly would be tedious, so this function handles it automatically.

Here’s what it does:

  • Content-Length – ensures it matches the body size so the client knows how much data to expect.
  • Date – adds the header if missing, which is standard in HTTP responses.
  • Connection / Keep-Alive – manages whether the connection stays open or closes.
  • Content-Type – detects the type automatically if not already set, so clients know how to handle the data (HTML, JSON, image, etc.).
response.go
1func (w *ResponseWriter) SetDefaultHeaders(body *[]byte) { 2 // Set Content-Length if not already set 3 if _, exists := (*w.Headers)["Content-Length"]; !exists { 4 w.Headers.Set("Content-Length", strconv.Itoa(len(*body))) 5 } 6 7 // Set Date if not already set 8 if _, exists := (*w.Headers)["Date"]; !exists { 9 w.Headers.Set("Date", time.Now().UTC().Format(time.RFC1123)) 10 } 11 12 // Set Connection / Keep-Alive headers 13 if _, exists := (*w.Headers)["Connection"]; !exists { 14 if w.isKeepAlive { 15 w.Headers.Set("Connection", "keep-alive") 16 w.Headers.Set("Keep-Alive", fmt.Sprintf("timeout=%d", int(w.idleTimeout.Seconds()))) 17 } else { 18 w.Headers.Set("Connection", "close") 19 } 20 } 21 22 // Set Content-Type if not already set and body exists 23 if len(*body) > 0 { 24 if _, exists := (*w.Headers)["Content-Type"]; !exists { 25 ct := detectContentType(body) 26 w.Headers.Set("Content-Type", string(ct)) 27 } 28 } 29} 30 31 32

The second function, SendResponse, puts everything together. Here’s how it works:

  1. Calls SetDefaultHeaders – prepares all required headers dynamically.
  2. Writes the status line – indicates the HTTP version and response status.
  3. Writes all headers – formats them correctly for HTTP.
  4. Writes the body, if present.

You might wonder why this function returns *types.RouteError. This is because it’s meant to be called at the endpoint handler level, so any errors while sending the response can be handled consistently in one place.

response.go
1 2func (w *ResponseWriter) SendResponse(body []byte) *types.RouteError { 3 w.SetDefaultHeaders(&body) 4 5 if err := w.WriteStatusLine(); err != nil { 6 return &types.RouteError{Code: types.InternalServerError, Message: err.Error()} 7 } 8 if err := w.WriteHeader(); err != nil { 9 return &types.RouteError{Code: types.InternalServerError, Message: err.Error()} 10 } 11 if len(body) > 0 { 12 if err := w.WriteBody(body); err != nil { 13 return &types.RouteError{Code: types.InternalServerError, Message: err.Error()} 14 } 15 } 16 return nil 17}

There are also other functions like getContentTypeFromExtension, SendFile, SendBadRequest, SendNotFound, and so on. These are straightforward and mostly self-explanatory. If you want to see exactly how they work, you can check the GitHub repo they handle common tasks like determining content type from a file extension, sending a file, or sending standard error responses.

Route

When we receive a request, we need a way to determine which handler to run. For example, we might have an endpoint like GET /api/info. This means that when a GET request is sent to this path, a specific function should run.

Another example is POST /edit/user/{id}. This endpoint uses the POST method to edit a user. The {id} in brackets indicates a dynamic value, meaning it can be any user ID. We need a way to capture this value so the handler knows which user to edit.

so the Handler that going to handle the request will have the following type, it will take response and request as pointer, and return type error

type Handler func(w *http.ResponseWriter, r *http.Request) *types.RouteError

the type error is like this, if the handler Fails will return this error or

error.go
1type RouteError struct { 2 Code StatusCode 3 Message string 4} 5 6func (r *RouteError) Error() string { 7 return r.Message 8} 9

Now we need a type to store these handlers so we can look them up when a request comes in. We organize them first by HTTP method, and under each method, we store the path as the key and the corresponding handler as the value.

type Routes map[types.Method]map[string]Handler


This function registers a new route. It takes an HTTP method, a path, and a handler function. If there’s no map for that method yet, it creates one, then stores the handler under the given path so the server knows which function to run when a matching request comes in.

route.go
1func (s *Server) Handle(method types.Method, path string, handler Handler) { 2 3 if s.routes[method] == nil { 4 s.routes[method] = make(map[string]Handler) 5 } 6 7 s.routes[method][path] = handler 8}

This function looks up which handler should run for an incoming request. First, it checks if there are any routes registered for the given HTTP method. Then it cleans and splits the request path into segments.

It compares the request path segments with each registered route’s segments. If a segment in the route is a parameter (like {id}), it captures the value from the request into a params map. If all segments match, it returns the corresponding handler.

If no matching route is found, it returns a default “Not Found” handler. If the HTTP method isn’t supported, it returns a “Method Not Allowed” handler.

route.go
1func (s *Server) FindRoute(path string, method types.Method) (Handler, url.Params) { 2 3 methodRoutes, ok := s.routes[method] 4 if !ok { 5 return http.MethodNotAllowedHandler, nil 6 } 7 8 url.CleanURL(&path) 9 10 reqSegments := strings.Split(strings.Trim(path, "/"), "/") 11 12 for routePath, handler := range methodRoutes { 13 routeSegments := strings.Split(strings.Trim(routePath, "/"), "/") 14 if len(routeSegments) != len(reqSegments) { 15 continue 16 } 17 18 params := make(url.Params) 19 matched := true 20 21 for i, seg := range reqSegments { 22 rSeg := routeSegments[i] 23 if url.IsParam(rSeg) { 24 paramName := rSeg[1 : len(rSeg)-1] 25 params[paramName] = seg 26 } else if rSeg != seg { 27 matched = false 28 break 29 } 30 } 31 32 if matched && handler != nil { 33 return handler, nil 34 } 35 } 36 37 return http.NotFoundHandler, nil 38} 39

Middleware

Middleware lets you run code before or after your main handler. You can chain multiple middleware functions that wrap around the final handler, so requests pass through them in order. for know it only run the middleware after the handler it run and it run for every single handler, later we can enhance it to make it run only for cretin group.
Middleware is a function type that takes a Handler and returns a new Handler. This lets you wrap extra logic around the main handler, like logging, authentication, or modifying requests/responses.

MiddlewareChain is a structure that stores multiple middleware functions in order. NewMiddlewareChain creates an empty chain ready to add middleware. This setup allows applying all middlewares to a handler in sequence.

Middleware.go
1type Middleware func(next Handler) Handler 2 3type MiddlewareChain struct { 4middlewares []Middleware 5} 6func NewMiddlewareChain() *MiddlewareChain { 7 return &MiddlewareChain{ 8 middlewares: make([]Middleware, 0), 9 } 10}

Use function This function adds a middleware to the chain.

  • mc.middlewares is a slice that stores all middleware functions in the order they were added.
  • append(mc.middlewares, middleware) adds the new middleware to the end of the slice.
  • This allows you to register multiple middlewares for the server, like logging, authentication, or request validation, which will all run before the final handler.

Apply function This function “wraps” the final handler with all the middlewares.

  • finalHandler is the actual endpoint handler you want to execute after all middleware logic.
  • handler := finalHandler starts with the endpoint itself.
  • The for loop goes through each middleware in the chain and wraps the current handler with it:

handler = m(handler)

  • After the loop, handler is a new function where all middlewares are applied in order, and calling it will first run middleware logic, then eventually the final handler.

Each middleware can decide to run some code before calling the next handler in the chain, giving you full control over request and response flow.

Middleware.go
1func (mc *MiddlewareChain) Use(middleware Middleware) { 2 mc.middlewares = append(mc.middlewares, middleware) 3} 4 5// Apply applies all middlewares to a handler in reverse order 6// M1 → M2 → M3 → FinalHandler 7func (mc *MiddlewareChain) Apply(finalHandler Handler) Handler { 8 handler := finalHandler 9 for _, m := range mc.middlewares { 10 handler = m(handler) 11 } 12 return handler 13} 14

Server

Here we manage clients, listen for incoming connections, handle routing, and trigger middlewares. let's see.

  • closed is a boolean that indicates whether the server is currently running or has been shut down.
  • listener is the network listener that waits for incoming TCP connections on a specified port.
  • idleTimeout defines how long the server will keep a connection alive when using Keep-Alive before closing it automatically.
  • middlewares stores a chain of middleware functions that can modify requests or responses before they reach the final route handler. we will see it later.
  • routes is a map of all the endpoints the server knows about, used to match incoming requests to the correct handler. we will see it later
server.go
1type Server struct { 2 closed bool 3 listener net.Listener 4 idleTimeout time.Duration 5 middlewares *MiddlewareChain 6 routes Routes 7} 8func NewServer(keepAlive time.Duration) *Server { 9 return &Server{ 10 closed: false, 11 idleTimeout: keepAlive, 12 middlewares: NewMiddlewareChain(), 13 routes: make(Routes), 14 } 15} 16

This function starts a new HTTP server on the given port. It first creates a server with a 10-second idle timeout, then opens a TCP listener on that port. If the listener fails, it returns an error. Otherwise, it assigns the listener to the server, starts accepting connections in a separate goroutine, and returns the running server.

server.go
1func ServeHTTP(port uint16) (*Server, error) { 2 server := NewServer(10 * time.Second) 3 listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) 4 if err != nil { 5 return nil, err 6 } 7 server.listener = listener 8 go server.acceptor() 9 return server, nil 10}

This function handles a single client connection. It’s the core of the server.

Close connection when done
defer conn.Close() ensures the connection is always closed when the function exits, regardless of errors or normal completion. This prevents resource leaks.

Loop to handle multiple requests
The for loop allows the server to handle multiple requests on the same connection. This is essential for HTTP Keep-Alive, where the client can send multiple requests without reopening a connection.

Set idle timeout
If the server has an idleTimeout set, it applies a deadline to the connection. This prevents a client from keeping the connection open indefinitely and helps free resources automatically if the connection is idle for too long.

Parse incoming request
The server reads the raw request from the client and converts it into a structured request object using http.ParseRequest(conn). If parsing fails, the server responds with a 400 Bad Request and exits, avoiding processing invalid data.

Prepare the response writer
http.NewResponseWriter(conn, s.idleTimeout) creates a ResponseWriter to handle sending responses back to the client. This abstracts away low-level details like writing headers, status, and body.

Find the route and handler
s.FindRoute looks up the request path and HTTP method in the server’s route table. It returns the correct handler function and any dynamic route parameters.

Apply middlewares
All middlewares are applied to the handler using s.middlewares.Apply(handler). This allows pre-processing, such as logging, authentication, or modifying requests/responses before reaching the final handler.

Set request parameters and Keep-Alive
Dynamic route parameters are attached to the request object. The server also checks if the client requested Keep-Alive. If so, it sets the response to keep the connection open for subsequent requests.

Execute the handler
The final handler is called with the request and response. If it returns an error, the server sends the appropriate HTTP response (404 Not Found, 400 Bad Request, 500 Internal Server Error) back to the client.

Close connection if not Keep-Alive
Finally, the function checks the Connection header. If the client requested keep-alive, the loop continues to handle more requests on the same connection. If the client wants the connection closed (or did not request Keep-Alive), the loop ends and the connection is automatically closed.

sever.go
1 2func handleConnection(conn net.Conn, s *Server) { 3 defer conn.Close() 4 5 for { 6 if s.idleTimeout > 0 { 7 _ = conn.SetDeadline(time.Now().Add(s.idleTimeout)) 8 } 9 10 req, err := http.ParseRequest(conn) 11 response := http.NewResponseWriter(conn, s.idleTimeout) 12 if err != nil { 13 response.SendBadRequest(err.Error()) 14 return 15 } 16 17 handler, params := s.FindRoute(req.RequestLine.Path, req.RequestLine.Method) 18 19 finalHandler := s.middlewares.Apply(handler) 20 21 req.Params = params 22 keepAlive := req.IsKeepAlive() 23 response.SetKeppAlive(keepAlive) 24 25 if routeErr := finalHandler(response, req); routeErr != nil { 26 switch routeErr.Code { 27 case types.NotFound: 28 response.SendNotFound(routeErr.Message) 29 case types.MethodNotAllowed: 30 response.SendBadRequest(routeErr.Message) 31 default: 32 response.SendInternalServerError(routeErr.Message) 33 } 34 } 35 if !keepAlive { 36 return 37 } 38 } 39}

testing

Let’s create some endpoints. We will serve HTML, CSS, and JavaScript files for a login page. If the user enters the correct username, it will show an alert; otherwise, it will display “Invalid username or password.” We will also create a middleware to log each request. as we can see we can register the handler like this first choose the method then the path and the last is the function

main.go
1server.Handle(types.GET, "/", sendIndex) 2server.Handle(types.GET, "/style.css", serveStatic) 3server.Handle(types.GET, "/script.js", serveStatic) 4server.Handle(types.POST, "/login", handleLogin)

let's see the function.sendIndex serves the main HTML page when the root path is requested. serveStatic serves other static files (CSS, JS, images) from the staticDir folder, defaulting to index.html if the path is /. handleLogin processes a simple login form: it parses the request body, checks if the username and password match a hardcoded combination, and responds with a JSON message indicating success or failure.

main.go
1const staticDir = "/home/mohe/Documents/github/my-server/static" 2 3// Serve root page 4func sendIndex(res *http.ResponseWriter, req *http.Request) *types.RouteError { 5 res.Status = types.OK 6 return res.SendFile(filepath.Join(staticDir, "index.html")) 7} 8 9// Serve static files 10func serveStatic(res *http.ResponseWriter, req *http.Request) *types.RouteError { 11 path := req.RequestLine.Path 12 if path == "/" || path == "." { 13 path = "/index.html" 14 } 15 16 fullPath := filepath.Join(staticDir, path) 17 res.Status = types.OK 18 19 return res.SendFile(fullPath) 20} 21 22// Handle login with simple logic 23func handleLogin(res *http.ResponseWriter, req *http.Request) *types.RouteError { 24 25 values, err := url.ParseQuery(string(req.Body)) 26 if err != nil { 27 res.Status = types.BadRequest 28 return &types.RouteError{Code: types.BadRequest, Message: "Invalid form data"} 29 } 30 31 username := values.Get("username") 32 password := values.Get("password") 33 34 if username == "admin" && password == "1234" { 35 return res.SendJSON(map[string]string{ 36 "status": "success", 37 "message": "Login successful", 38 }, types.OK) 39 } 40 41 return res.SendJSON(map[string]string{ 42 "status": "error", 43 "message": "Invalid credentials", 44 }, types.Unauthorized) 45}

Midleware

server.Use(LoggingMiddleware) tells the server to run LoggingMiddleware on every incoming request before it reaches the final endpoint handler. we explain this method above.

main.go
1server.Use(LoggingMiddleware)

This function defines a middleware called LoggingMiddleware.

It takes a Handler (the next function in the chain) and returns a new Handler that wraps it.

When a request comes in, this middleware first logs the request method, path, and HTTP version to the console. After logging, it calls the next handler in the chain (next(w, r)), so the request continues to its intended endpoint.

main.go
1 2func LoggingMiddleware(next internals.Handler) internals.Handler { 3 return func(w *http.ResponseWriter, r *http.Request) *types.RouteError { 4 log.Printf("[%s] %s %s\n", r.RequestLine.Method, r.RequestLine.Path, r.RequestLine.Version) 5 return next(w, r) 6 } 7}

let's run the server and go to the localhost:8080/.

Let’s test it if we enter the correct username and password, it should show an alert message. Indeed, it works, and we can see the POST request being received by the server.

Access parameters

This sets up a search endpoint that captures a dynamic parameter from the URL. When a request is made to /search/{firstID}, the Search handler runs. It retrieves the firstID value from the route parameters and prints it to the server console. This shows how you can access dynamic URL segments in your handler.

main.og
1func Search(res *http.ResponseWriter, req *http.Request) *types.RouteError { 2 firstID, _ := req.Params.Get("firstID") 3 fmt.Println("FirstID:", first) 4 return nil 5} 6 7func main(){ 8.... 9server.Handle(types.GET, "/search/{firstID}", Search) 10..... 11}

What we can do more?

  • Chunked Encoding – Send responses in smaller pieces for streaming large data or files.
  • Binary Data Support – Serve images, PDFs, or other non-text content efficiently.
  • TLS/HTTPS Support – Secure connections using SSL/TLS.
  • Compression – Add gzip or Brotli compression to reduce response sizes.
  • Caching – Implement ETag or Last-Modified headers for client-side caching.
  • Rate Limiting – Limit requests per IP to prevent abuse.
  • Error Pages – Custom 404, 500, and other error pages.
  • Session Management – Track users via cookies or tokens.
  • WebSocket Support – Upgrade connections for real-time communication.
  • Static File Caching – Serve static files with proper cache headers.

Published Sep 1, 2025

  • If we still need more data, return so the caller can read the next chunk.
  • Once the full body is read, we mark the request as done.
  • Done (StateDone)
    • We’ve finished parsing everything, so just return the number of bytes consumed.