An HTTP handler that decodes a request body without a byte limit can consume far more input than its application-level schema suggests. A JSON object with three fields may still arrive inside a multi-gigabyte body. Decoder validation controls structure; it does not establish a transport-sized boundary.

Go’s http.MaxBytesReader places that boundary directly around the request body. It returns an io.ReadCloser that permits reads up to a configured limit and reports an error when code attempts to read beyond it.

The limit applies while the body is read

A handler can replace r.Body before passing it to a decoder:

func createHandler(w http.ResponseWriter, r *http.Request) {
    const maxBody = 1 << 20 // 1 MiB

    r.Body = http.MaxBytesReader(w, r.Body, maxBody)
    defer r.Body.Close()

    var input struct {
        Name string `json:"name"`
    }

    dec := json.NewDecoder(r.Body)
    if err := dec.Decode(&input); err != nil {
        var tooLarge *http.MaxBytesError
        if errors.As(err, &tooLarge) {
            http.Error(w, "request body is too large", http.StatusRequestEntityTooLarge)
            return
        }

        http.Error(w, "invalid request body", http.StatusBadRequest)
        return
    }

    w.WriteHeader(http.StatusNoContent)
}

The wrapper does not reject a request merely because its declared Content-Length exceeds the limit. Enforcement happens as the body is consumed. This matters for requests without a known length, including chunked HTTP/1.1 bodies, and it keeps the boundary tied to bytes actually presented through the reader.

When a read crosses the configured maximum, the returned error can be matched as *http.MaxBytesError. Its Limit field records the configured byte limit. That typed error lets a handler separate an oversized request from malformed JSON, truncated input, or another read failure.

A size boundary and a syntax boundary solve different problems

json.Decoder can reject malformed JSON and, with DisallowUnknownFields, reject object members outside an expected schema. Neither setting limits the total number of bytes the decoder may consume.

http.MaxBytesReader supplies the byte boundary underneath the decoder. The decoder still owns JSON syntax and field rules. Keeping those responsibilities separate produces clearer failure handling: an input can be valid JSON but too large, or small enough but syntactically invalid.

The same distinction applies to form parsing and custom protocols. A parser describes acceptable content. A bounded reader controls how much content the parser is allowed to inspect.

Reading past the boundary is significant

A limit only has an effect when code reads enough data to reach it. Consider a handler that reads a short prefix and returns immediately. If the prefix fits inside the limit, MaxBytesReader has no basis to report that additional unread bytes exist.

For decoders expected to consume one complete document, this creates another concern: accepting the first valid value while ignoring trailing input. A JSON endpoint that requires exactly one value can perform a second decode and require io.EOF:

if err := dec.Decode(&input); err != nil {
    // classify the decode error
    return
}

var extra any
if err := dec.Decode(&extra); err != io.EOF {
    // reject trailing data or classify a size error
    return
}

That second read also gives the bounded body a chance to detect excess bytes that occur after an otherwise valid first JSON value.

The wrapper remains a closer

Unlike io.LimitReader, http.MaxBytesReader returns an io.ReadCloser. Closing it closes the wrapped request body. A handler can therefore replace r.Body and retain the normal defer r.Body.Close() pattern without keeping a second reference solely for cleanup.

The HTTP-specific wrapper also has server integration that a plain io.LimitReader does not provide. The standard library documents it as intended for limiting incoming request bodies, and an over-limit read returns a non-nil *http.MaxBytesError rather than presenting the boundary as ordinary end-of-file.

That distinction is useful at an API boundary. EOF normally means the sender finished supplying data. A size-limit error means the application intentionally stopped accepting more data.

Limit placement affects the whole handler chain

The byte limit should wrap the body before any component that may consume it. Middleware that reads the body for logging, signature checks, decompression, or request inspection can otherwise process bytes before the handler installs its boundary.

For a limit that applies uniformly to a route subtree, http.MaxBytesHandler can wrap an http.Handler and run it with a body already constrained by MaxBytesReader:

mux.Handle("/upload", http.MaxBytesHandler(uploadHandler, 8<<20))

A handler-local MaxBytesReader remains useful when different endpoints or operations require different limits. The key property is placement: the boundary has to exist before the first body read that it is meant to govern.

Compressed input needs a separate decoded-size decision

A limit around r.Body counts bytes from the HTTP request body as exposed at that point. If application code later decompresses those bytes, a small compressed payload can expand into a much larger stream.

Endpoints that accept compressed request content therefore have two distinct quantities to consider: encoded bytes received over HTTP and decoded bytes supplied to the parser. http.MaxBytesReader can bound the first quantity. If decoded expansion also needs a ceiling, the decompressed stream needs its own limit before parsing.

A request-body limit is most useful when treated as an explicit resource boundary rather than a substitute for validation. http.MaxBytesReader establishes how many incoming bytes a handler will accept; parsers and application checks still decide what those bytes are allowed to mean.