---
title: "Understanding HTTP 304, ETag, Cache-Control, and Last-Modified with Go"
description: "Learn how to implement HTTP 304 responses, ETags, and caching headers in Go to optimize your web server's performance and reduce bandwidth usage."
slug: "understanding-http-304-etag-cache-control-and-last-modified-with-go"
url: "https://furkanbaytekin.dev/blogs/understanding-http-304-etag-cache-control-and-last-modified-with-go"
author: "Furkan Baytekin"
category: "Software"
tags: []
published: 2025-05-16T07:55:41Z
updated: 2025-05-16T07:55:41Z
readTime: 7
cover: "https://myblogcms.furkanbaytekin.dev/uploads/cover/understanding-http-304-etag-cache-control-and-last-modified-with-go+1747381821486"
---

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.

```go
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:

```bash
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
```

4. Make a second request with the `If-None-Match` and `If-Modified-Since` headers (browsers do this automatically):

```bash
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:

<iframe style="border-radius:12px" src="https://open.spotify.com/embed/album/02HTMA352Lk31hKW0vq7UV?utm_source=generator" width="100%" height="152" frameBorder="0" allowfullscreen="" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy"></iframe>
