http.ServeFile actually does handle range requests correctly for static files. The problem comes when you try to build something more sophisticated: transcoding on the fly, live streaming, or HLS segmentation. This post covers each scenario with working code, and explains what http.ServeFile does and does not handle.
How video players work#
A video player does not request the entire file in one HTTP call. It sends a Range request:
GET /video.mp4 HTTP/1.1
Range: bytes=0-1048575The server responds with 206 Partial Content and the requested byte range:
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-1048575/52428800
Accept-Ranges: bytes
Content-Length: 1048576When the user seeks to a different position, the player sends a new range request for the byte offset corresponding to that timestamp. Without range request support, seeking resets to the beginning.
Serving a static video file with range support#
For a static file on disk, http.ServeFile handles range requests automatically. This is the correct and complete implementation:
package main
import (
"log"
"net/http"
)
func main() {
http.HandleFunc("/video/", func(w http.ResponseWriter, r *http.Request) {
// ServeFile handles Range headers, 206 responses, ETags, and If-Modified-Since.
// The path after /video/ maps to the file system.
http.ServeFile(w, r, "videos/"+r.URL.Path[len("/video/"):])
})
log.Println("serving on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}http.ServeFile is appropriate for static files. It handles Range, ETag, If-Modified-Since, If-None-Match and If-Range headers. Use it for static video delivery. The rest of this post covers scenarios where you need more control.
Implementing range requests manually#
When you are serving bytes from something other than a file (a buffer, a stream, a transcoded output), you need to implement range handling yourself:
package rangeserver
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
)
// ServeRange serves the file at path with full range request support.
func ServeRange(w http.ResponseWriter, r *http.Request, path string) {
f, err := os.Open(path)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
defer f.Close()
info, err := f.Stat()
if err != nil {
http.Error(w, "stat error", http.StatusInternalServerError)
return
}
totalSize := info.Size()
w.Header().Set("Accept-Ranges", "bytes")
w.Header().Set("Content-Type", "video/mp4")
rangeHeader := r.Header.Get("Range")
if rangeHeader == "" {
w.Header().Set("Content-Length", strconv.FormatInt(totalSize, 10))
w.WriteHeader(http.StatusOK)
io.Copy(w, f)
return
}
// Parse "bytes=start-end"
start, end, err := parseRange(rangeHeader, totalSize)
if err != nil {
http.Error(w, "invalid range", http.StatusRequestedRangeNotSatisfiable)
return
}
length := end - start + 1
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, totalSize))
w.Header().Set("Content-Length", strconv.FormatInt(length, 10))
w.WriteHeader(http.StatusPartialContent)
f.Seek(start, io.SeekStart)
io.CopyN(w, f, length)
}
func parseRange(header string, total int64) (int64, int64, error) {
if !strings.HasPrefix(header, "bytes=") {
return 0, 0, fmt.Errorf("unsupported range unit")
}
parts := strings.Split(header[6:], "-")
if len(parts) != 2 {
return 0, 0, fmt.Errorf("malformed range")
}
start, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return 0, 0, err
}
end := total - 1
if parts[1] != "" {
end, err = strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return 0, 0, err
}
}
if start < 0 || end >= total || start > end {
return 0, 0, fmt.Errorf("range out of bounds")
}
return start, end, nil
}HLS segmentation with FFmpeg#
HLS (HTTP Live Streaming) splits the video into small .ts segments and generates an .m3u8 playlist manifest. Players download the manifest, then fetch segments sequentially. This enables adaptive bitrate streaming and reliable seeking.
Use exec.Command to run FFmpeg as a subprocess:
package hls
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
)
// Segment transcodes inputPath into HLS segments in outputDir.
// It blocks until FFmpeg finishes (VOD use case).
func Segment(inputPath, outputDir string) error {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("create output dir: %w", err)
}
manifestPath := filepath.Join(outputDir, "index.m3u8")
segmentPattern := filepath.Join(outputDir, "segment%03d.ts")
cmd := exec.Command(
"ffmpeg",
"-i", inputPath,
"-codec:", "copy", // Copy streams without re-encoding (fast, no quality loss)
"-start_number", "0",
"-hls_time", "6", // Target segment duration in seconds
"-hls_list_size", "0", // 0 = keep all segments in manifest (VOD)
"-hls_segment_filename", segmentPattern,
"-f", "hls",
manifestPath,
)
// Capture stderr for debugging. FFmpeg writes its log there.
cmd.Stderr = os.Stderr
log.Printf("segmenting %s -> %s", inputPath, outputDir)
if err := cmd.Run(); err != nil {
return fmt.Errorf("ffmpeg: %w", err)
}
log.Printf("segmentation complete: %s", manifestPath)
return nil
}Using -codec: copy copies streams without re-encoding. It is fast and lossless but requires the input to already be in a container-compatible codec (H.264 video, AAC audio for HLS). If you need to transcode, replace with -c:v libx264 -c:a aac and add bitrate flags.
Serving HLS from Go#
Serve the .m3u8 manifest and .ts segments with the correct MIME types and CORS headers:
package hls
import (
"net/http"
"path/filepath"
"strings"
)
// Handler serves HLS files from dir.
func Handler(dir string) http.Handler {
fs := http.FileServer(http.Dir(dir))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// CORS headers are required for HLS playback from a different origin
// (e.g., a video.js player on a different port).
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
// Set correct MIME types.
ext := strings.ToLower(filepath.Ext(r.URL.Path))
switch ext {
case ".m3u8":
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
w.Header().Set("Cache-Control", "no-cache") // Manifest changes during live
case ".ts":
w.Header().Set("Content-Type", "video/mp2t")
w.Header().Set("Cache-Control", "public, max-age=3600") // Segments are immutable
}
fs.ServeHTTP(w, r)
})
}Wire it up:
package main
import (
"flag"
"log"
"net/http"
"github.com/yourname/video/hls"
)
func main() {
segDir := flag.String("dir", "./segments", "HLS segment directory")
addr := flag.String("addr", ":8080", "listen address")
flag.Parse()
mux := http.NewServeMux()
mux.Handle("/hls/", http.StripPrefix("/hls/", hls.Handler(*segDir)))
log.Printf("HLS server on %s, serving from %s", *addr, *segDir)
log.Fatal(http.ListenAndServe(*addr, mux))
}Players access http://localhost:8080/hls/index.m3u8.
Live streaming with FFmpeg#
For live streaming from a webcam or RTMP source, FFmpeg writes segments continuously to a directory. The Go server serves them. The manifest uses a finite hls_list_size and the player fetches it repeatedly:
package hls
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
)
// StartLive starts a live HLS stream from inputSource into outputDir.
// It respects ctx for graceful cancellation.
func StartLive(ctx context.Context, inputSource, outputDir string) error {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("create output dir: %w", err)
}
manifestPath := filepath.Join(outputDir, "live.m3u8")
segmentPattern := filepath.Join(outputDir, "live%05d.ts")
cmd := exec.CommandContext(ctx,
"ffmpeg",
"-re", // Read input at native frame rate (for file inputs)
"-i", inputSource, // e.g., "rtmp://localhost/live/stream" or "/dev/video0"
"-c:v", "libx264",
"-preset", "veryfast", // Low CPU, low latency
"-tune", "zerolatency",
"-c:a", "aac",
"-hls_time", "2", // Short segments for low latency
"-hls_list_size", "5", // Keep 5 segments in manifest (sliding window)
"-hls_flags", "delete_segments+append_list",
"-hls_segment_filename", segmentPattern,
"-f", "hls",
manifestPath,
)
cmd.Stderr = os.Stderr
log.Printf("starting live stream: %s -> %s", inputSource, outputDir)
if err := cmd.Start(); err != nil {
return fmt.Errorf("ffmpeg start: %w", err)
}
// Wait for FFmpeg to exit (context cancellation stops it via CommandContext).
if err := cmd.Wait(); err != nil && ctx.Err() == nil {
return fmt.Errorf("ffmpeg exited: %w", err)
}
return nil
}-hls_flags delete_segments removes old .ts files from disk. This prevents unbounded disk growth during long live streams but means segments that are no longer in the manifest will 404 if a slow player requests them. Tune hls_list_size and segment duration based on your latency/buffer trade-off.
Progressive download vs HLS vs DASH#
How it works: Single MP4 file served with range request support.
Pros: Simple. One file. Works everywhere.
Cons: No adaptive bitrate. Seeking requires downloading to that byte offset if the file is not already buffered. Not suitable for live streams.
When to use: Short clips, downloads, simple video embeds.
http.HandleFunc("/video/", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "./videos/"+r.URL.Path[len("/video/"):])
})How it works: Video split into .ts segments, .m3u8 manifest. Player fetches manifest, then segments.
Pros: Native support on iOS and Safari. Adaptive bitrate (multiple quality variants). Works for live. Seekable without full download.
Cons: Higher latency than progressive download (minimum ~2x segment duration). Requires FFmpeg or similar to segment.
When to use: Mobile video, live streaming, anything that needs adaptive bitrate.
How it works: Similar to HLS but uses .mpd XML manifest and .m4s fragmented MP4 segments.
Pros: Open standard. Better browser support on Android. More flexible segment structure.
Cons: No native iOS/Safari support without a player library (dash.js, Shaka Player). More complex manifest format.
When to use: When you need cross-platform adaptive bitrate without native Safari HLS overhead.
Production: use a CDN for segment distribution#
flowchart LR
Source["Source\n(camera / file)"] --> FFmpeg["FFmpeg\n(Go subprocess)"]
FFmpeg --> Segments["Segments on disk\n(.m3u8 + .ts files)"]
Segments --> GoServer["Go HTTP Server\n(origin)"]
GoServer --> CDN["CDN\n(CloudFront / CloudFlare)"]
CDN --> Players["Video Players\n(millions)"]
The Go server in this post handles segment generation and acts as the origin server. For any real traffic, put a CDN in front of it. CloudFront can cache .ts segments (they are immutable once written) with a long TTL. The manifest (.m3u8) should be served with Cache-Control: no-cache or a very short TTL so players always get the current segment list.
Go is for generating segments. The CDN is for distributing them at scale. Do not serve video segments directly from your Go process at any meaningful traffic level.
If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.