Ever made a fetch()
call from JavaScript and wondered what's actually happening on the other end? Or used Gin/Echo/Fiber and typed r.GET("/users", handler)
without thinking about what makes that route registration work?
If you're a frontend engineer who's curious about what happens after your API request leaves the browser, or a backend developer who wants to understand what's beneath those convenient framework abstractions, this series is for you.
Who This Series Is For
Frontend Engineers: You know how to make HTTP requests, but what's actually running on that server responding to your POST /api/users
calls? We'll build it from scratch.
Backend Developers: You've written gin.GET("/users/:id", getUserHandler)
, but do you know how that path matching actually works? What happens between the network request hitting your server and your handler function being called?
We're going to build all of this ourselves - no frameworks, just Go's standard library and our own code.
Prerequisites
- Go installed: Download from https://golang.org/dl/
- curl installed: Most systems have it, or get it from https://curl.se/download.html
- Text editor: VS Code, Vim, or any editor you prefer
The Problem We're Solving
When your JavaScript does this:
fetch('/api/users/123', { method: 'GET' })
Or your mobile app makes this call:
URLSession.shared.dataTask(with: url)
Something has to be listening on the other end, parse that HTTP request, figure out what /api/users/123
means, and send back a response. Today, we'll build that "something" from first principles.
Setting Up Our Project
First, let's create our Go module:
mkdir custom-http-server && cd custom-http-server/chap1
go mod init webserver
This creates a go.mod
file that manages our dependencies.
The Simplest Possible Server
Let's start with the most basic question: How do we create something that can listen for HTTP requests?
When you use Gin, you write:
r := gin.Default()
r.GET("/", func(c *gin.Context) { c.String(200, "hello world") })
r.Run(":3000")
But what is Gin actually doing under the hood? Let's build our own version to understand.
Go's standard library provides everything we need through the net/http
package. But instead of just calling http.ListenAndServe()
, let's build our own server struct to understand the underlying concepts.
Create server.go
:
package main
import (
"fmt"
"log"
"net/http"
)
type Server struct {
Addr string
server *http.Server
}
Why are we creating a Server
struct instead of just using the standard library directly? This gives us:
-
Encapsulation: All server-related logic stays together (just like Gin's
Engine
) - Extensibility: We can easily add features like middleware, custom routing, etc.
- Testability: We can create multiple server instances for testing
Now let's add a constructor function:
func NewServer(addr string) Server {
return Server{
Addr: addr,
}
}
Implementing the HTTP Handler
Here's where it gets interesting. For our server to handle HTTP requests, it needs to implement the http.Handler
interface. This interface has just one method:
ServeHTTP(ResponseWriter, *Request)
This is the method that gets called every time an HTTP request comes in - whether it's your frontend's fetch()
call, a mobile app request, or someone testing with curl.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("hello world"))
}
What's happening here?
-
w http.ResponseWriter
: This is how we send data back to the client (like writingres.json()
in Express orc.JSON()
in Gin) -
r *http.Request
: This contains all the information about the incoming request - the path, method, headers, body, everything -
w.WriteHeader()
: Sets the HTTP status code (200 OK in this case) -
w.Write()
: Sends the actual response body back to the client
Starting Our Server
Now we need a way to start our server and make it listen for incoming connections:
func (s *Server) Start() {
s.server = &http.Server{
Addr: s.Addr,
Handler: s, // This is crucial - we're telling Go to use our ServeHTTP method
}
fmt.Println("Server starting at", s.Addr)
err := s.server.ListenAndServe()
if err != nil {
log.Fatal(err)
}
}
The key insight here is Handler: s
- we're passing our entire server struct as the handler. Go will call our ServeHTTP
method for every incoming request.
Putting It All Together
Create main.go
:
package main
func main() {
server := NewServer(":3000")
server.Start()
}
Testing Our Server
Let's see our server in action:
go run .
You should see:
Server starting at :3000
Now let's test it like a frontend developer would. In another terminal:
curl http://localhost:3000
Expected Response:
hello world
Try different paths (like your frontend might request):
curl http://localhost:3000/api/users
curl http://localhost:3000/api/products/123
curl -X POST http://localhost:3000/api/login -d '{"username":"test"}'
What do you notice? Every request returns the same "hello world" response, regardless of the path or HTTP method. This is because our ServeHTTP
method doesn't look at the request details yet - it's like having one giant catch-all route handler.
What We've Built So Far
We now have:
- ✅ A working HTTP server that can receive any HTTP request
- ✅ Custom server struct for extensibility (foundation for adding features)
- ✅ Understanding of the
http.Handler
interface (what frameworks build on top of) - ✅ Basic request/response cycle (what happens when your frontend makes a request)
Current Limitations
Our server has the same issues you'd face with one giant route handler in Express:
- Single Response: Every request gets the same "hello world" response
-
No Route Awareness: We ignore the requested path completely (
/users
vs/products
) - No Method Handling: GET, POST, PUT, DELETE all behave the same
- No Request Data: We don't read query parameters, headers, or request body
Imagine if every API endpoint in your frontend returned the same response - not very useful!
What's Next?
In Chapter 2, we'll address the first major limitation - handling different routes. We'll modify our ServeHTTP
method to inspect the incoming request path and return different responses based on what the client requested.
But here's a preview of what we'll discover: handling routes directly in ServeHTTP
quickly becomes unwieldy (like having one massive if-else chain). This will set us up perfectly for Chapter 3, where we'll build a proper routing system - similar to how Express or Gin handles routes.
Try This Challenge: Before moving to Chapter 2, try modifying the ServeHTTP
method to print the request path and method to the console. This will help you see exactly what requests are coming in from your tests.
Hint: Use fmt.Printf("Request: %s %s\n", r.Method, r.URL.Path)
Next: Chapter 2: Handling Multiple Routes - The Monolithic Approach
Top comments (0)