InteractiveFrameworks

Body limits

Refuse a request body before a handler reads it: a JSON limit with a message of your own, and a raw upload limit with the middleware’s default 413.

What you'll learn

  • Put bodyLimit({ maxSize }) in front of a route and see the 413 Payload Too Large it answers by default
  • Replace the refusal with onError, so the API’s errors keep one shape
  • Know what the limit measures: bytes on the wire, from Content-Length when present and from the stream otherwise

A handler that calls await c.req.json() reads the whole body into memory before it can look at a single field. Nothing in the API so far says how big that body may be, so a client, or a mistake in a client, can send a megabyte where a cat's name was expected, and the runtime dutifully buffers it. On a server that is memory and time spent on a request that should have been refused at once; on an edge runtime with a per-request budget it is the request failing in a way you did not choose. bodyLimit refuses before the handler runs, and this lesson puts it on the two routes of the cats API that accept a body.

bodyLimit()

import { bodyLimit } from 'hono/body-limit';

app.post(
  '/upload',
  bodyLimit({
    maxSize: 50 * 1024, // 50kb
    onError: (c) => c.text('overflow :(', 413),
  }),
  async (c) => {
    const body = await c.req.parseBody();
    return c.text('pass :)');
  },
);

maxSize is the limit in bytes. Without onError the middleware answers 413 with the text Payload Too Large; with it, your function builds the response, which is how the refusal keeps the same JSON shape as the API's other errors. Like every middleware, it goes on a prefix with app.use() or between a path and its handler, and a route that never reads a body needs none.

Always pass maxSize. The docs mention a 100 KB default, but the implementation has none: leave it out and nothing is refused.

What is measured

The middleware trusts Content-Length when the request carries it and no Transfer-Encoding: a number larger than maxSize is refused before a byte of the body is read. Otherwise, a chunked upload or a client that sent no length, it reads the body itself, counting, and stops the moment the count passes the limit; a body that fits is handed on to the handler unchanged, so c.req.json() and c.req.arrayBuffer() work as before. The comparison is strict: a body of exactly maxSize bytes passes.

The unit is bytes on the wire, not characters. A name in Cyrillic or an emoji is two to four bytes per character in UTF-8, and JSON's quotes, braces and commas count too, so a limit should be set from a real payload, with room.

Where it sits

The limit runs before the handler and before any validator, since a validator has to read the body to check it. Registered on a prefix, it protects every route under it, including routes added later; on a single route, it says something about that route. The refusal is an HTTPException carrying the response, so app.onError() from Basics sees it like any other and, if it forwards err.getResponse(), keeps the middleware's answer.

Two things the limit does not do. It does not know what the runtime in front of the app allows: Bun's server refuses bodies over its own limit before Hono sees them, and the docs note the setting to raise. And it does not protect a route that reads its body from somewhere else, such as a stream the handler pipes without buffering; a limit on a route that streams is still worth having, since the middleware counts what passes through.

Your task

Both body-reading routes of the cats API are unguarded.

  1. POST /cats accepts at most 200 bytes; over that, answer 413 with { "error": "Body too large: 200 bytes at most" } without running the handler.
  2. POST /cats/:id/photo accepts at most 1024 bytes, refused with the middleware's own answer.

Send POST /cats from the request bar with a notes field of a few hundred characters and read the response.

When it fails

  • A 300-byte cat is stored with 201: the middleware is registered after the handler in the argument list, or on a different route. It goes between the path and the handler.
  • The big cat is refused with Payload Too Large as text: onError is missing on the cats route. The photo route wants the default; the cats route wants the JSON.
  • Everything is refused, small bodies too: maxSize was written in kilobytes or characters. It is bytes.
  • Nothing is ever refused: maxSize was left out. There is no default.

Remember

  • bodyLimit({ maxSize, onError }) refuses a body over maxSize bytes with 413; onError shapes the answer.
  • It trusts Content-Length and otherwise reads and counts, stopping at the limit; a body that fits reaches the handler intact.
  • Bytes, not characters; strict, so exactly maxSize passes; and there is no default.
  • Put it before validators and handlers, on the routes that read a body.
Stuck? Show a hint

bodyLimit({ maxSize: 200, onError: (c) => c.json({ error: … }, 413) }) goes between the path and the handler. A second bodyLimit with only maxSize keeps the default text answer. Nothing else in the handlers changes.