Understanding HTTP 304, ETag, Cache-Control, and Last-Modified with Go

Understanding HTTP 304, ETag, Cache-Control, and Last-Modified with Go

Learn how to implement HTTP 304 responses, ETags, and caching headers in Go to optimize your web server's performance and reduce bandwidth usage.

In the world of web development, optimizing performance is critical for delivering fast and efficient user experiences. HTTP caching mechanisms like HTTP 304 Not Modified, ETag, Cache-Control, and Last-Modified play a pivotal role in reducing server load and improving page load times. In this article, we'll explore these concepts and demonstrate how to implement them in Go with a simple example.


What is HTTP 304 Not Modified?

The HTTP 304 Not Modified status code is part of the HTTP caching mechanism. It tells the client (e.g., a browser) that the requested resource hasn’t changed since the last time it was fetched. Instead of sending the full resource again, the server responds with a 304 status, allowing the client to use its cached version.

This saves bandwidth and speeds up page loads, especially for static assets like images, CSS, or JavaScript files.


Key Caching Concepts

ETag (Entity Tag)

An ETag is a unique identifier assigned to a specific version of a resource. It’s sent in the HTTP response header (ETag) and used by the client in subsequent requests via the If-None-Match header. If the ETag matches the server’s current resource, the server responds with a 304 status, indicating the resource hasn’t changed.

Cache-Control

The Cache-Control header defines how a resource should be cached, how long it should be cached, and who can cache it (e.g., browsers, CDNs). Common directives include:

  • max-age=<seconds>: Specifies how long the resource is considered fresh.
  • no-cache: Forces the client to validate with the server before using the cached version.
  • public or private: Indicates whether the resource can be cached by intermediaries (e.g., CDNs).

Last-Modified

The Last-Modified header indicates the timestamp of the resource’s last modification. The client includes this timestamp in the If-Modified-Since header in subsequent requests. If the resource hasn’t been modified since that time, the server returns a 304 status.


How These Work Together

Here’s a typical flow:

  1. The client requests a resource.
  2. The server responds with the resource, including ETag, Cache-Control, and Last-Modified headers.
  3. On the next request, the client sends If-None-Match (with the ETag) and If-Modified-Since (with the Last-Modified timestamp).
  4. The server checks if the resource has changed:
    • If unchanged, it responds with 304 Not Modified.
    • If changed, it sends the updated resource with new headers.

This process ensures efficient use of cached resources, reducing unnecessary data transfers.


Implementing HTTP 304 and Caching in Go

Let’s build a simple Go HTTP server that implements ETag, Cache-Control, and Last-Modified to support HTTP 304 responses.

Example Code

Below is a Go program that serves a static file (example.txt) and implements caching headers.

package main

import (
	"crypto/md5"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"sync"
	"time"
)

const (
	filePath        = "example.txt"
	maxInMemorySize = 2 * 1024 * 1024 // 2MB
)

type CachedFile struct {
	Content      []byte
	ETag         string
	LastModified time.Time
	UseCache     bool
	Path         string
	Size         int64
	mu           sync.RWMutex
}

func main() {
	cf, err := loadFile(filePath)
	if err != nil {
		log.Fatal(err)
	}
	http.HandleFunc("/resource", serveResource(cf))
	log.Println("Serving on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

func loadFile(path string) (*CachedFile, error) {
	file, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer file.Close()

	stat, err := file.Stat()
	if err != nil {
		return nil, err
	}

	cf := &CachedFile{
		LastModified: stat.ModTime().Truncate(time.Second),
		Path:         path,
		Size:         stat.Size(),
	}

	if stat.Size() <= maxInMemorySize {
		data, err := io.ReadAll(file)
		if err != nil {
			return nil, err
		}
		cf.Content = data
		hash := md5.Sum(data)
		cf.ETag = fmt.Sprintf(`"%x"`, hash)
		cf.UseCache = true
	} else {
		cf.ETag = fmt.Sprintf(`W/"%x-%x"`, stat.ModTime().Unix(), stat.Size())
		cf.UseCache = false
	}

	return cf, nil
}

func serveResource(cf *CachedFile) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		cf.mu.RLock()
		etag := cf.ETag
		lastMod := cf.LastModified
		cached := cf.UseCache
		content := cf.Content
		path := cf.Path
		cf.mu.RUnlock()

		if r.Header.Get("If-None-Match") == etag {
			w.WriteHeader(http.StatusNotModified)
			return
		}

		w.Header().Set("ETag", etag)
		w.Header().Set("Last-Modified", lastMod.Format(http.TimeFormat))
		w.Header().Set("Cache-Control", "max-age=3600, public")
		w.Header().Set("Content-Type", "text/plain")

		if cached {
			_, err := w.Write(content)
			if err != nil {
				http.Error(w, "Error writing response", http.StatusInternalServerError)
			}
		} else {
			file, err := os.Open(path)
			if err != nil {
				http.Error(w, "Error opening file", http.StatusInternalServerError)
				return
			}
			defer file.Close()
			_, err = io.Copy(w, file)
			if err != nil {
				http.Error(w, "Error streaming file", http.StatusInternalServerError)
			}
		}
	}
}

How It Works

  1. File Metadata Loading On startup, the server reads the file’s size and last modified timestamp.
  2. Smart Caching Strategy If the file size is less than or equal to 2 MB, it’s loaded into memory for faster access.
  3. ETag Generation
    • For small files: a strong ETag is generated using an MD5 hash of the content.
    • For large files: a weak ETag is created based on the file's size and last modified time.
  4. Client Cache Validation Incoming requests are checked for the If-None-Match header. If the ETag matches, a 304 Not Modified response is returned.
  5. Content Delivery
    • If cached: content is served directly from memory.
    • If not cached: the file is streamed from disk on each request.

Testing the Server

  1. Create a file named example.txt with some content (e.g., "Hello, World!").
  2. Run the Go program: go run server.go.
  3. Use a tool like curl or a browser to test:
curl -i http://localhost:8080/resource -v

# Output
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8080...
* Connected to localhost (::1) port 8080
> GET /resource HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
HTTP/1.1 200 OK
< Cache-Control: max-age=3600, public
Cache-Control: max-age=3600, public
< Content-Type: text/plain
Content-Type: text/plain
< Etag: 8ddd8be4b179a529afa5f2ffae4b9858
Etag: 8ddd8be4b179a529afa5f2ffae4b9858
< Last-Modified: Fri, 16 May 2025 10:17:57 GMT
Last-Modified: Fri, 16 May 2025 10:17:57 GMT
< Date: Fri, 16 May 2025 07:20:36 GMT
Date: Fri, 16 May 2025 07:20:36 GMT
< Content-Length: 13
Content-Length: 13
<

Hello World!
* Connection #0 to host localhost left intact
  1. Make a second request with the If-None-Match and If-Modified-Since headers (browsers do this automatically):
curl -i -H "If-None-Match: <etag-from-previous-response>" -H "If-Modified-Since: <last-modified-from-previous-response>" http://localhost:8080/resource

# Example
curl -i -H "If-None-Match: 8ddd8be4b179a529afa5f2ffae4b9858" -H "If-Modified-Since: Fri, 16 May 2025 10:17:57 GMT" http://localhost:8080/resource

# Output
HTTP/1.1 304 Not Modified
Date: Fri, 16 May 2025 07:26:37 GMT

If the file hasn’t changed, you’ll see a 304 Not Modified response.


SEO and Performance Benefits

Implementing HTTP 304 and caching headers offers several benefits:

  • Improved Page Load Speed: Cached resources reduce server requests and data transfers.
  • Reduced Server Load: Fewer full responses mean less strain on your server.
  • Better SEO: Search engines like Google prioritize fast-loading websites, and caching helps achieve this.
  • Enhanced User Experience: Faster load times lead to happier users and lower bounce rates.

Best Practices

  1. Use Strong ETags: Generate ETags based on content (e.g., MD5 hash) on small files for accuracy.
  2. Use Weak ETags: For large files, use a weak ETag based on size and modTime.
  3. Use Memory Cache: For small files, use a memory cache to serve requests faster.
  4. Set Appropriate Cache-Control: Tailor max-age and other directives to your content’s update frequency.
  5. Combine with Other Optimizations: Use compression (e.g., Gzip) and CDNs alongside caching.
  6. Test Thoroughly: Use tools like Lighthouse or WebPageTest to ensure your caching strategy is effective.

Conclusion

HTTP 304, ETag, Cache-Control, and Last-Modified are powerful tools for optimizing web performance. By implementing these in Go, you can create efficient, scalable web servers that deliver fast and responsive experiences. The example above demonstrates a simple yet effective way to get started with caching in Go.

Try experimenting with different Cache-Control directives or integrating this into a larger Go application. Happy coding!


Album of the day: