HTTP/1, HTTP/2, and HTTP/3

There have been four1 major versions of HTTP in-use in the last 30-ish years: Version RFC Year HTTP/1.0 RFC 1945 1996 HTTP/1.1 RFC 2068 1997 HTTP/2 RFC 7540 2015 HTTP/3 RFC 9114 2022 It’s surprising to me how quickly HTTP/1.1 was released after HTTP/1.0. Less than one year! HTTP/1.0 A simple, text-based protocol. Runs over TCP. Only officially supports a single HTTP request and response over each TCP connection, then the connection was torn down. HTTP/1.1 Still a text-based protocol. Added connection re-use. On by default. Controlled via the Connection: keep-alive header and tuned via Keep-Alive. Added the Host header, which allows multiple websites to be hosted at a single IP address. Better cache control mechanisms. Added HTTP pipelining (allowing multiple requests to be sent in succession before responses were received). Apparently never got widespread support, though. Still suffers from Head of Line blocking. Even though TCP connections get re-used, a given request/response cycle must complete before the connection is eligible for re-use. Many browsers improved throughput by allowing up to 6 TCP connections per domain. Websites took this even further with domain sharding. Example: Sharding resources over www1.foo.bar, www2.foo.bar, www3.foo.bar gives you 18 connections to play with. No compression of headers. Lots of redundant data sent for each request/response in the headers, which is inefficient. HTTP/2 Based on pioneering work done by Google with their SPDY (pronounced: “speedy”) protocol in the 2010s. No longer a text-based protocol; now a binary-based protocol. Allows request multiplexing over a single TCP connection. Solved the application-layer Head of Line Blocking problem, but did not solve it for the transport layer (e.g., a dropped TCP packet would still delay all requests/responses behind it). Adds header compression. HTTP/3 Still a binary-based protocol. Moved from TCP to UDP+QUIC for the transport layer. Many improvements come from this. Faster connection setup Old: Setting up a TLS connection with older protocols required around three round trips (3-RTT): the TCP handshake (around 1-RTT) and TLS handshake (2-RTT). New: Setting up a TLS connection with QUIC requires a single round trip (1-RTT) because the transport connection setup and TLS handshakes are merged. Note that 0-RTT resumption is possible with QUIC as well. Avoids the transport-layer head of line blocking problem by using multiple streams. Head of line blocking now fixed at both HTTP and transport layer. Faster connection migration via Connection IDs. (Allows a client to switch networks seamlessly while browsing.) Closing It was interesting going down this rabbit hole on HTTP. ...

2025-07-09

System Design Patterns by Sean Goedecke

This blog post by Sean Goedecke was a great read on high-level system design patterns. Most of them were familiar to me, but I got several good ideas from it. Using a database as queue: Sometimes you want to roll your own queue system. For instance, if you want to enqueue a job to run in a month, you probably shouldn’t put an item on the Redis queue. Redis persistence is typically not guaranteed over that period of time (and even if it is, you likely want to be able to query for those far-future enqueued jobs in a way that would be tricky with the Redis job queue). In this case, I typically create a database table for the pending operation with columns for each param plus a scheduled_at column. I then use a daily job to check for these items with scheduled_at <= today, and either delete them or mark them as complete once the job has finished. ...

2025-07-02

Tools to Organize Your Life

Much productivity advice on the internet is too complicated and not actually that helpful. Complicated systems are less likely to be used due to the friction of using them and are more brittle in the face of changing needs. You are better off use basic, flexible systems; you are more likely to use simple systems and simple systems can adapt to change more easily. There are only a few tools you need to completely run your personal life. They are: ...

2025-06-25

Keep Email Useful

I get the impression that a lot of people are overwhelmed by their personal email inbox nowadays and get so much email that they’ve more or less given up on it. I don’t have that problem and that’s by design, not by accident. This post explains the rules I use to keep my personal email useful and very manageable. Unsubscribe aggressively Any time you give your email address out nowadays, you will get put on a mailing list. ...

2025-06-19

Echo Router Parameters

I regularly have to look up how to create an Echo router context with (a) path parameters and (b) query parameters, so I’m making this post as a cheat sheet for my future self. Path parameters Example: /some/random/path/:id // create your context ctx := echo.New().NewContext(request, response) // add your path param ctx.SetParamNames("id") ctx.SetParamValues("foo") Query parameters Example: /some/random/path?key1=value1&key2=value2 // create your request request := httptest.NewRequest(http.MethodPost, "/some/random/path", nil) // get existing query params query := request.URL.Query() // add or set new params query.Add("key1", "value1") query.Set("key2", "value2") // encode the query params back to the request request.URL.RawQuery = query.Encode() // create your context with the request ctx := echo.New().NewContext(request, response)

2025-06-06

Go Backoff Algorithms

I ran across a blog post by Josh Bleecher Snyder today which has some beautiful backoff algorithms in Go. Capturing them here in case his blog ever goes offline. Algorithm 1 func do(ctx context.Context) error { const ( maxAttempts = 10 baseDelay = 1 * time.Second maxDelay = 60 * time.Second ) delay := baseDelay for attempt := range maxAttempts { err := request(ctx) if err == nil { return nil } delay *= 2 delay = min(delay, maxDelay) jitter := multiplyDuration(delay, rand.Float64()*0.5-0.25) // ±25% sleepTime := delay + jitter select { case <-ctx.Done(): return ctx.Err() case <-time.After(sleepTime): } } return fmt.Errorf("failed after %d attempts", maxAttempts) } func multiplyDuration(d time.Duration, mul float64) time.Duration { return time.Duration(float64(d) * mul) } Algorithm 2 I really like this one. It’s very easy to read. ...

2025-06-04

How to view and remove routes on macOS

Today I needed to remove a bad route on macOS. Normally I just reboot to do that, but I decided to learn how to do it without rebooting. Here’s how I did it. First, view the routes to confirm the route is bad: netstat -rn | grep IP_ADDR Grab the IP you care about from the “Destination” column. Use it to delete the route: sudo route -n delete DEST_IP_ADDR Done!

2025-05-30

Fixing a filename in git on a case insensitive filesystem

My work laptop was provisioned with a case-insensitive filesystem for some strange reason. This makes renaming stuff in a git repo “fun”: $mv README.md readme.md mv: 'README.md' and 'readme.md' are the same file Here’s a workaround for this problem: # give the file a temp name git mv README.md readme_temp.md git com -m "Rename 1 of 2" # then name it what you actually want to call it git mv readme_temp.md readme.md git com -m "Rename 2 of 2" Solved!

2025-05-29

Grep's Buffering + Pipes

I got bit by this again today, so I’m writing it down so I can reference it later. When running docker logs -f CONTAINER_NAME | grep -v "foo" | jq, it is important to consider grep’s buffers. I am using GNU grep and, by default, it uses block buffering when not connected to a terminal (which is my case, since I piped it to jq). This means jq doesn’t get any input until a large-ish amount of text (4k? 8k?) is sent to grep. Since container I was inspecting logs little, I got zero log lines. ...

2025-05-28

Playing around with trees in Golang

I was playing around with implementing Depth First Search (DFS) and Breadth First Search (BFS) in Go today. I made a tree which looked like this… 0 / \ 1 2 / \ / \ 3 4 5 6 / \ 7 8 I then made some Go code to walk the tree using DFS and BFS: package main import "fmt" // Node is a very basic struct which represents // a node in the tree. type Node struct { Value int Left *Node Right *Node } // dfs() walks the tree (starting at the provided root node) // in a depth first fashion. It uses a stack. func dfs(node *Node) { // base case if node == nil { return } fmt.Println("DFS visiting:", node.Value) if node.Left != nil { dfs(node.Left) } if node.Right != nil { dfs(node.Right) } } // bfs() walks the tree (starting at the provided root node) // in a breadth first fashion. It uses a queue. func bfs(node *Node) { // base case if node == nil { return } queue := []*Node{node} depth := 0 for len(queue) >= 1 { for _, node := range queue { queue = queue[1:] fmt.Printf("BFS visiting: %d (depth: %d)\n", node.Value, depth) if node.Left != nil { queue = append(queue, node.Left) } if node.Right != nil { queue = append(queue, node.Right) } } depth += 1 } } func main() { // define our tree (see above diagram) tree := &Node{ Value: 0, Left: &Node{ Value: 1, Left: &Node{ Value: 3, }, Right: &Node{ Value: 4, }, }, Right: &Node{ Value: 2, Left: &Node{ Value: 5, }, Right: &Node{ Value: 6, Left: &Node{ Value: 7, }, Right: &Node{ Value: 8, }, }, }, } // call our funcs dfs(tree) bfs(tree) } Running the code outputs: ...

2025-05-20