diff --git a/go.mod b/go.mod
index 6035d76c08b9b118bf8e91bd419360584485d365..e7c9744e9d47e172a4ec08f068093083003f9090 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.11
 
 require (
 	contrib.go.opencensus.io/exporter/zipkin v0.1.2
+	github.com/NYTimes/gziphandler v1.1.1
 	github.com/amoghe/go-crypt v0.0.0-20191109212615-b2ff80594b7f
 	github.com/bbrks/wrap/v2 v2.5.0
 	github.com/cenkalti/backoff/v4 v4.1.1
diff --git a/go.sum b/go.sum
index 1817d0e7c7a057022c01e2dcf09f78d0acefbbf3..981f144dcb9dd8f0abcf538dfa8fc174d10fc081 100644
--- a/go.sum
+++ b/go.sum
@@ -5,6 +5,8 @@ contrib.go.opencensus.io/exporter/zipkin v0.1.2/go.mod h1:mP5xM3rrgOjpn79MM8fZbj
 github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c h1:/IBSNwUN8+eKzUzbJPqhK839ygXJ82sde8x3ogr6R28=
 github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I=
+github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
 github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
 github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
 github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
@@ -30,6 +32,7 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
 github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI=
 github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
 github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
@@ -177,6 +180,7 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/theckman/go-flock v0.8.1 h1:kTixuOsFBOtGYSTLRLWK6GOs1hk/8OD11sR1pDd0dl4=
 github.com/theckman/go-flock v0.8.1/go.mod h1:kjuth3y9VJ2aNlkNEO99G/8lp9fMIKaGyBmh84IBheM=
@@ -295,6 +299,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/serverutil/http.go b/serverutil/http.go
index 3975b21c9f0fd194dc03311829bf38a64a840f82..6a2cc3d1736505fe0ed12339f89a1d458382817b 100644
--- a/serverutil/http.go
+++ b/serverutil/http.go
@@ -1,6 +1,7 @@
 package serverutil
 
 import (
+	"compress/gzip"
 	"context"
 	"crypto/tls"
 	"fmt"
@@ -16,12 +17,25 @@ import (
 	"time"
 
 	"git.autistici.org/ai3/go-common/tracing"
+	"github.com/NYTimes/gziphandler"
 	"github.com/coreos/go-systemd/v22/daemon"
 	"github.com/prometheus/client_golang/prometheus"
 	"github.com/prometheus/client_golang/prometheus/promhttp"
 )
 
-var gracefulShutdownTimeout = 3 * time.Second
+var (
+	gracefulShutdownTimeout = 3 * time.Second
+
+	gzipLevel        = gzip.BestSpeed
+	gzipMinSize      = 1300
+	gzipContentTypes = []string{
+		"application/json",
+		"application/javascript",
+		"text/html",
+		"text/plain",
+		"text/css",
+	}
+)
 
 // ServerConfig stores common HTTP/HTTPS server configuration parameters.
 type ServerConfig struct {
@@ -29,20 +43,23 @@ type ServerConfig struct {
 	MaxInflightRequests int              `yaml:"max_inflight_requests"`
 	RequestTimeoutSecs  int              `yaml:"request_timeout"`
 	TrustedForwarders   []string         `yaml:"trusted_forwarders"`
+
+	// TODO: switch do disable_compression (flip default) later.
+	EnableCompression bool `yaml:"enable_compression"`
 }
 
-func (config *ServerConfig) buildHTTPServer(h http.Handler) (*http.Server, error) {
+func (config *ServerConfig) buildHTTPHandler(h http.Handler) (http.Handler, *tls.Config, error) {
 	var tlsConfig *tls.Config
 	var err error
 	if config != nil {
 		if config.TLS != nil {
 			tlsConfig, err = config.TLS.TLSConfig()
 			if err != nil {
-				return nil, err
+				return nil, nil, err
 			}
 			h, err = config.TLS.TLSAuthWrapper(h)
 			if err != nil {
-				return nil, err
+				return nil, nil, err
 			}
 		}
 
@@ -51,7 +68,7 @@ func (config *ServerConfig) buildHTTPServer(h http.Handler) (*http.Server, error
 		if len(config.TrustedForwarders) > 0 {
 			h, err = newProxyHeaders(h, config.TrustedForwarders)
 			if err != nil {
-				return nil, err
+				return nil, nil, err
 			}
 		}
 
@@ -68,15 +85,23 @@ func (config *ServerConfig) buildHTTPServer(h http.Handler) (*http.Server, error
 		}
 	}
 
-	// These are not meant to be external-facing servers, so we
-	// can be generous with the timeouts to keep the number of
-	// reconnections low.
-	return &http.Server{
-		Handler:           addDefaultHandlers(h),
-		ReadHeaderTimeout: 30 * time.Second,
-		IdleTimeout:       600 * time.Second,
-		TLSConfig:         tlsConfig,
-	}, nil
+	// Add all the default handlers (health, monitoring, etc).
+	h = addDefaultHandlers(h)
+
+	// Optionally enable compression.
+	if config.EnableCompression {
+		gzwrap, err := gziphandler.GzipHandlerWithOpts(
+			gziphandler.CompressionLevel(gzipLevel),
+			gziphandler.MinSize(gzipMinSize),
+			gziphandler.ContentTypes(gzipContentTypes),
+		)
+		if err != nil {
+			return nil, nil, err
+		}
+		h = gzwrap(h)
+	}
+
+	return h, tlsConfig, nil
 }
 
 // Serve HTTP(S) content on the specified address. If config.TLS is
@@ -90,12 +115,22 @@ func Serve(h http.Handler, config *ServerConfig, addr string) error {
 	// debugging endpoints).
 	h = tracing.WrapHandler(h, guessEndpointName(addr))
 
-	// Create the HTTP server.
-	srv, err := config.buildHTTPServer(h)
+	// Create the top-level HTTP handler with all our additions.
+	hh, tlsConfig, err := config.buildHTTPHandler(h)
 	if err != nil {
 		return err
 	}
 
+	// These are not meant to be external-facing servers, so we
+	// can be generous with the timeouts to keep the number of
+	// reconnections low.
+	srv := &http.Server{
+		Handler:           hh,
+		ReadHeaderTimeout: 30 * time.Second,
+		IdleTimeout:       600 * time.Second,
+		TLSConfig:         tlsConfig,
+	}
+
 	// Create the net.Listener first, so we can detect
 	// initialization-time errors safely.
 	l, err := net.Listen("tcp", addr)
diff --git a/serverutil/http_test.go b/serverutil/http_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..77403add1d8e7715e94023f694a45558724e2b42
--- /dev/null
+++ b/serverutil/http_test.go
@@ -0,0 +1,142 @@
+package serverutil
+
+import (
+	"context"
+	"crypto/rand"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"git.autistici.org/ai3/go-common/clientutil"
+)
+
+type TestRequest struct {
+	Data []string `json:"data"`
+}
+
+type TestObject struct {
+	Name      string    `json:"name"`
+	Host      string    `json:"host"`
+	Timestamp time.Time `json:"timestamp"`
+	PubKey    []byte    `json:"pubkey"`
+}
+
+type TestResponse struct {
+	Objects []*TestObject `json:"objects"`
+}
+
+func fastRandomBytes(n int) []byte {
+	b := make([]byte, n)
+	rand.Read(b) // nolint: errcheck
+	return b
+}
+
+func makeTestHandler() http.HandlerFunc {
+	// Generate a large-ish random response.
+	var resp TestResponse
+	now := time.Now()
+	n := 256
+	resp.Objects = make([]*TestObject, 0, n)
+	for i := 0; i < n; i++ {
+		resp.Objects = append(resp.Objects, &TestObject{
+			Name:      fmt.Sprintf("test-object-%06d", i+1),
+			Host:      "host-452-ff-bb",
+			Timestamp: now,
+			PubKey:    fastRandomBytes(256),
+		})
+	}
+
+	return func(w http.ResponseWriter, httpReq *http.Request) {
+		var req TestRequest
+		if !DecodeJSONRequest(w, httpReq, &req) {
+			return
+		}
+		EncodeJSONResponse(w, &resp)
+	}
+}
+
+const apiPath = "/api/v1/random"
+
+func makeTestRequest() *TestRequest {
+	var req TestRequest
+	n := 256
+	req.Data = make([]string, 0, n)
+	for i := 0; i < n; i++ {
+		req.Data = append(req.Data, fmt.Sprintf("data-item-%06d", i))
+	}
+	return &req
+}
+
+func makeSingleRequest(backend clientutil.Backend, req *TestRequest) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	var resp TestResponse
+	return backend.Call(ctx, "", apiPath, &req, &resp)
+}
+
+func runHTTPTest(t *testing.T, config *ServerConfig) {
+	mux := http.NewServeMux()
+	mux.HandleFunc(apiPath, makeTestHandler())
+
+	h, _, err := config.buildHTTPHandler(mux)
+	if err != nil {
+		t.Fatal(err)
+	}
+	srv := httptest.NewServer(h)
+	defer srv.Close()
+
+	backend, err := clientutil.NewBackend(&clientutil.BackendConfig{
+		URL: srv.URL,
+	})
+	if err != nil {
+		t.Fatalf("NewBackend() error: %v", err)
+	}
+	defer backend.Close()
+
+	if err := makeSingleRequest(backend, makeTestRequest()); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestHTTP(t *testing.T) {
+	runHTTPTest(t, &ServerConfig{})
+}
+
+func TestHTTP_Compression(t *testing.T) {
+	runHTTPTest(t, &ServerConfig{
+		EnableCompression: true,
+	})
+}
+
+func BenchmarkLoad(b *testing.B) {
+	mux := http.NewServeMux()
+	mux.HandleFunc(apiPath, makeTestHandler())
+
+	config := &ServerConfig{
+		EnableCompression: true,
+	}
+	h, _, _ := config.buildHTTPHandler(mux)
+	srv := httptest.NewServer(h)
+	defer srv.Close()
+
+	backend, err := clientutil.NewBackend(&clientutil.BackendConfig{
+		URL: srv.URL,
+	})
+	if err != nil {
+		b.Fatalf("NewBackend() error: %v", err)
+	}
+	defer backend.Close()
+
+	req := makeTestRequest()
+
+	// Run clients.
+	b.SetParallelism(100)
+	b.RunParallel(func(pb *testing.PB) {
+		for pb.Next() {
+			makeSingleRequest(backend, req) // nolint: errcheck
+		}
+	})
+}
diff --git a/serverutil/json.go b/serverutil/json.go
index da3cfe38686f246e7090d5758e62b7a59cac7529..3034b4706305f686c91d25ae88abc5d8c05ec096 100644
--- a/serverutil/json.go
+++ b/serverutil/json.go
@@ -30,19 +30,15 @@ func DecodeJSONRequest(w http.ResponseWriter, r *http.Request, obj interface{})
 
 // EncodeJSONResponse writes an application/json response to w.
 func EncodeJSONResponse(w http.ResponseWriter, obj interface{}) {
-	data, err := json.Marshal(obj)
-	if err != nil {
-		log.Printf("JSON serialization error: %v", err)
-		http.Error(w, err.Error(), http.StatusInternalServerError)
-		return
-	}
-
 	w.Header().Set("Content-Type", "application/json")
 	w.Header().Set("Pragma", "no-cache")
 	w.Header().Set("Cache-Control", "no-store")
 	w.Header().Set("Expires", "-1")
 	w.Header().Set("X-Content-Type-Options", "nosniff")
-	if _, err = w.Write(data); err != nil {
-		log.Printf("error writing response: %v", err)
+
+	err := json.NewEncoder(w).Encode(obj)
+	if err != nil {
+		log.Printf("error writing JSON response: %v", err)
+		// Too late to return an error to the client now.
 	}
 }