Skip to content
gohacks.dev
Web

Download Files in Go With Range Requests and ServeContent

Serve large files from Go with http.ServeContent so browsers can resume downloads and seek in video, and see exactly what each Range header gets back.

9 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

Somebody on a bad connection starts downloading your 2 GB export, loses signal at 1.4 GB, and the browser asks for the rest instead of starting again. Somebody else drags the seek bar of a video you serve to the middle and playback starts within a second. Both depend on one HTTP feature, byte ranges, and in Go you get it by calling the right function rather than by implementing it. This article shows what http.ServeContent does for each kind of Range header, so you can recognise it in a trace (the MDN reference for Range is a good companion) and know when it applies.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

It belongs to the web cluster that ends in a production HTTP server, and it pairs with serving static files and the client side of the same trick, where a downloader resumes from where it stopped. Everything here ran on Go 1.25.6 with the standard library only.

What the server has to do

A client that wants part of a file sends Range: bytes=0-499. A server that supports ranges answers 206 Partial Content, with a Content-Range: bytes 0-499/1000 header saying which slice it is and how big the whole thing is, and a body of exactly those 500 bytes. A server that does not support them ignores the header and returns 200 with the full body, which is still correct, only slower. To announce support up front it sends Accept-Ranges: bytes on ordinary responses.

That sounds small, and the edge cases are not. A suffix range (bytes=-500, the last 500 bytes). An open-ended range (bytes=900-). Several ranges in one request, which needs a multipart/byteranges body. A range that starts beyond the end of the file. A HEAD request that must send the same headers without the body. A client that asks for the rest of a file, but only if it has not changed. The standard library implements all of it in http.ServeContent, and the specification is RFC 9110, section 14 if you want to read the rules it follows.

One handler, every kind of request

ServeContent(w, r, name, modtime, content) takes the file name (used only to guess the Content-Type from the extension), the modification time, and an io.ReadSeeker. The seeker is not a convenience. The function calls Seek(0, io.SeekEnd) to learn the size, then seeks to the start of whichever range was requested. A bytes.Reader and an *os.File both qualify; a plain io.Reader such as an HTTP response body does not.

The program below serves a 1,000 byte slice and then sends it a series of requests, printing what comes back.

main.goGo
package main

import (
	"bytes"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"time"
)

func main() {
	data := bytes.Repeat([]byte("0123456789"), 100) // 1000 bytes
	modified := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.ServeContent(w, r, "digits.txt", modified, bytes.NewReader(data))
	}))
	defer srv.Close()

	try := func(label, method string, header map[string]string) {
		req, _ := http.NewRequest(method, srv.URL, nil)
		for k, v := range header {
			req.Header.Set(k, v)
		}
		res, err := http.DefaultClient.Do(req)
		if err != nil {
			fmt.Println(label, "error:", err)
			return
		}
		defer res.Body.Close()
		body, _ := io.ReadAll(res.Body)
		preview := string(body)
		if len(preview) > 12 {
			preview = preview[:12] + "..."
		}
		ctype := res.Header.Get("Content-Type")
		if i := strings.Index(ctype, ";"); i >= 0 {
			ctype = ctype[:i]
		}
		fmt.Printf("%-22s %d  len=%-4d range=%-18q type=%s body=%q\n",
			label, res.StatusCode, len(body), res.Header.Get("Content-Range"), ctype, preview)
	}

	try("whole file", "GET", nil)
	try("HEAD", "HEAD", nil)
	try("first ten bytes", "GET", map[string]string{"Range": "bytes=0-9"})
	try("from 990 to the end", "GET", map[string]string{"Range": "bytes=990-"})
	try("last five bytes", "GET", map[string]string{"Range": "bytes=-5"})
	try("two ranges", "GET", map[string]string{"Range": "bytes=0-4,10-14"})
	try("past the end", "GET", map[string]string{"Range": "bytes=2000-3000"})
	try("malformed", "GET", map[string]string{"Range": "bytes=abc"})
	try("unit we do not know", "GET", map[string]string{"Range": "items=0-3"})
	try("If-Range matches", "GET", map[string]string{"Range": "bytes=0-4", "If-Range": modified.Format(http.TimeFormat)})
	try("If-Range is stale", "GET", map[string]string{"Range": "bytes=0-4", "If-Range": "Wed, 01 Jan 2020 00:00:00 GMT"})
	try("If-Modified-Since", "GET", map[string]string{"If-Modified-Since": modified.Format(http.TimeFormat)})
}
Text
whole file             200  len=1000 range=""                 type=text/plain body="012345678901..."
HEAD                   200  len=0    range=""                 type=text/plain body=""
first ten bytes        206  len=10   range="bytes 0-9/1000"   type=text/plain body="0123456789"
from 990 to the end    206  len=10   range="bytes 990-999/1000" type=text/plain body="0123456789"
last five bytes        206  len=5    range="bytes 995-999/1000" type=text/plain body="56789"
two ranges             206  len=358  range=""                 type=multipart/byteranges body="--59bbdcbb93..."
past the end           416  len=33   range="bytes */1000"     type=text/plain body="invalid rang..."
malformed              416  len=14   range=""                 type=text/plain body="invalid rang..."
unit we do not know    416  len=14   range=""                 type=text/plain body="invalid rang..."
If-Range matches       206  len=5    range="bytes 0-4/1000"   type=text/plain body="01234"
If-Range is stale      200  len=1000 range=""                 type=text/plain body="012345678901..."
If-Modified-Since      304  len=0    range=""                 type= body=""

Read the output line by line, because each line is a rule you would otherwise have to code.

A plain request is a 200 with all 1,000 bytes. HEAD gets the same headers and no body. The first ten bytes come back as 206 with Content-Range: bytes 0-9/1000. The open-ended and suffix forms are resolved against the real size, which the function found by seeking. Two ranges in one request switch the response to multipart/byteranges and the Content-Range header disappears, because each part carries its own.

A range that starts beyond the end gets 416 Range Not Satisfiable with Content-Range: bytes */1000, the form that tells the client how big the file really is. A malformed value (bytes=abc) and a unit the server does not know (items=0-3) also get 416, but without a Content-Range. The specification lets a server ignore a Range header it cannot interpret, and Go chooses the stricter reading, so a client that sends nonsense hears about it.

If-Range is what makes a resume safe

Suppose a client downloaded the first 400 bytes of a file, the file was replaced on the server, and the client now asks for bytes 400 onward. Splicing the old head onto the new tail produces a file that looks fine and is corrupt. If-Range exists to prevent that. The client sends the validator it saw the first time, either the Last-Modified date or an ETag, together with Range. If it still matches, the server honours the range. If not, the server ignores Range and sends the whole current file with 200, and the client knows to start over.

In the output above, the matching date got a 206, and the stale one got a full 200. ServeContent does this comparison for you, and it is the main reason I do not hand-write range handling: the failure mode is silent corruption on a few downloads a week, never a test failure.

The date is also what makes If-Modified-Since work, giving 304 Not Modified with no body when the browser's copy is current. That only happens if you pass a real modtime. A zero time.Time{} means "unknown": no Last-Modified header is sent and no conditional logic runs. For a file on disk, use info.ModTime(). For generated content, use the time it was generated, and keep it stable between requests, because a modtime of time.Now() on every request defeats caching and breaks If-Range for every resume.

Serving a real file

For a file on disk you rarely need to call ServeContent yourself. http.ServeFile(w, r, path) opens the file, stats it and calls ServeContent with the right arguments. Do not pass it a path built from user input without cleaning it; ServeFile has a guard against .. in the request URL, but not against the string you built. The safer shape when the file name comes from a request is http.FileServer over an http.Dir rooted at the download directory, or an fs.FS you control.

When you need headers ServeFile does not set, such as Content-Disposition for a download name, open the file yourself:

Go
func download(w http.ResponseWriter, r *http.Request) {
	f, err := os.Open("exports/report.csv")
	if err != nil {
		http.NotFound(w, r)
		return
	}
	defer f.Close()
	info, err := f.Stat()
	if err != nil {
		http.Error(w, "stat failed", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Disposition", `attachment; filename="report.csv"`)
	http.ServeContent(w, r, info.Name(), info.ModTime(), f)
}

Set your own headers before the call, since ServeContent writes the status line. It will not overwrite a Content-Type you already set; it guesses from the extension, and if that finds nothing it reads the first 512 bytes and sniffs. That sniffing is why an upload stored under a hostile name is dangerous to serve back, which is the subject of file upload handling.

Things that can go wrong

Compression and ranges do not mix. A range refers to byte offsets in the representation being sent. If a middleware gzips the response, the offsets no longer line up with the file, and the client's second request gets a different encoding of the same range. Either skip compression for download routes or compress at a layer that understands the range, and see the gzip middleware for how to exclude paths. Already-compressed formats (zip, mp4, jpeg) gain nothing from gzip anyway.

Timeouts hit big files. A server-wide WriteTimeout of 10 seconds cuts a 2 GB download at the knees of a slow client. Adjust the deadline for the download route with http.ResponseController rather than raising the timeout for every endpoint.

Content that is not seekable cannot be ranged. If the bytes come from a database row streamed through a cursor or from another service, you have an io.Reader. Options: spool it to a temporary file first, fetch from object storage with the storage API's own range support, or accept that the response is not resumable and use plain io.Copy. Wrapping a reader in something that pretends to seek is how you get truncated downloads.

A huge file and a slow client hold a goroutine and a file descriptor for as long as the transfer lasts. That is normal for downloads, but it is why a limit on concurrent downloads belongs in front of the route.

When not to serve it yourself

If the file lives in S3 or any object store, issue a presigned URL and let the store do the work; it handles ranges, bandwidth and retries better than your process will, and your service stops being a pipe for bytes. Serving through Go makes sense when you must authorise every request against your own database, or when the file is generated on the fly and small enough to hold. For the rest, ServeContent is the right tool and the shortest handler you will write this week.

Test it the way the program above does: httptest plus real Range headers, asserting on the status and Content-Range rather than on the body alone. A regression where someone swaps ServeContent for io.Copy shows up immediately, because every range request starts returning 200.

Taggednet http
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.