knext

Request hardening

Rate limiting, payload-size caps, and malformed-request handling for a knext app — what the platform gives you, what it does not, and the recipe for the rest.

knext bounds how many requests hit your app, how long each may run, and how large a single request body may be — on every build. It does not bound request rate per client; use the recipes below for that. This page is precise about which is which, because the difference decides whether you need to do anything.

Request bodies are capped at 8 MiB by default, for every route, on every build — the default standalone build (Turbopack or webpack, on Node or Bun, compiled or not) and the vinext build alike. A larger body is answered with 413, and the cap counts bytes rather than trusting Content-Length — so a chunked request that sends no length is refused too. Change it with the KNEXT_MAX_REQUEST_BYTES environment variable; see The request body cap.

The 8 MiB cap is not the same limit as Next.js's. Next caps Server Action payloads at 1 MB by default, and that setting does not cover App Router route handlers. The two are deliberately different numbers so that a 413 tells you which one fired: 1 MB is Next, 8 MiB (or whatever you set) is knext.

What the platform already bounds

  • Request body size — 8 MiB by default, on every route and every build, enforced inside the app's own process, so it applies to traffic from your proxy, from the Knative ingress, and from a pod dialling your app directly. See The request body cap.
  • Concurrency per pod — spec.scaling.containerConcurrency (default 20) is enforced by the Knative queue-proxy inside the pod, so it applies to every request that reaches your app.
  • Request duration — spec.timeoutSeconds (default 300s) caps how long one request may occupy a concurrency slot. Lower it per app if you have no long-running streaming responses.
  • Scale ceiling — spec.scaling.maxScale (default 10) bounds how far a flood can scale you out, which is what turns an overload into a bounded cost rather than an unbounded one.
  • Direct-dial isolation — the default NetworkPolicy admits only the queue-proxy and metrics ports, so a pod sharing your namespace cannot bypass the queue-proxy by dialling your container port. See Security → Network isolation. This is enforced only if your CNI supports NetworkPolicy (Calico, Cilium); on flannel the policy is inert. knext doctor and the NetworkPolicyEnforced condition on your app both report which case your cluster is in.
  • Malformed HTTP — external traffic passes three independent HTTP parsers before your code.
  • Malformed request paths — on the vinext build, the scaffolded server entry answers a path that is not valid percent-encoding (for example a % not followed by two hex digits) with 400 Bad Request before any route runs, and turns any other failure on the request path into a plain 500 rather than an error page or a process exit. These files are written once, when the app is scaffolded: to pick this up in an older app, run knext create --builder vinext in a throwaway directory and copy its runtime-contract.mjs and your runtime's entry (knext-bun-entry.mjs or knext-node-entry.mjs) over yours — diff first if you edited them. On the Node runtime, knext doctor warns when the entry is behind the installed version. On the default standalone build (Turbopack or webpack, on Node or on the compiled Bun executable), the app port is served entirely by Next.js's own request handling — knext does not sit in front of it for this — and a malformed path never reaches knext code. The :9464 metrics listener is knext's own code, and it ships as two separate implementations depending on how the app was built: a regular knext build runs one metrics listener alongside the standalone server, while knext build --self-contained folds an independent metrics listener directly into the compiled executable instead (no separate process). Both match the request target with a plain string comparison, never a URL parser, so neither is exposed to this class of bug.
  • Metrics-port separation — the app's Prometheus listener defaults to :9464, deliberately off :9091, which Knative's queue-proxy binds inside every pod on a stock Serving install (default config-observability). The NetworkPolicy's scrape grants, the prometheus.io/port annotation and the shipped PodMonitor are all fixed to 9464. Never set METRICS_PORT=9091 — the app races queue-proxy for the port and crash-loops with EADDRINUSE; knext doctor detects this.

What it does not bound

GapWhat it means in practice
Request rate per clientNothing limits how fast one client can call you. A flood scales you out to maxScale and stops there.
Slow clients in aggregateBounded per pod by concurrency and by timeoutSeconds, but not globally.

The recipe

1. Put a proxy in front — for external traffic

Terminate external traffic at a proxy that enforces rate limits and payload caps before they reach the cluster: your cloud load balancer's policies, or an nginx/Envoy ingress layer.

A front proxy binds external traffic only. It does nothing about a pod inside your cluster calling your app — that is what the NetworkPolicy above is for. Three more caveats worth knowing before you rely on one:

  • Rate-limit counters are per replica. Three proxy replicas with "100 req/min" allow 300 unless the proxy shares state (Redis-backed limiter or equivalent).
  • Chunked bodies have no Content-Length. Enforcing a size cap on them requires the proxy to buffer — which moves the memory pressure to the proxy rather than removing it.
  • Cloud LB payload policies vary. Verify your provider actually offers one; several do not.

2. Set the request body cap

knext caps request bodies in-process, so this control applies whether the traffic came through your proxy, through the Knative ingress, or from a pod inside the cluster dialling your app directly. It is the same variable, default and rules on every build: on the standalone build the knext runtime loads the cap into the Next.js server process before it starts; on the vinext build the scaffolded knext-bun-entry.mjs and knext-node-entry.mjs read it.

  • Default: 8388608 bytes (8 MiB). Chosen against the memory limit and containerConcurrency (20 in-flight × 8 MiB stays far inside a 1Gi pod), and deliberately above Next's 1 MB Server Action limit so the two never answer at the same threshold.

  • The runtime counts bytes. A body sent with chunked transfer encoding and no Content-Length is refused at the same size as one that declares its length. Content-Length is never trusted on its own.

  • An oversized body never reaches your handler. A request that declares a Content-Length above the cap is refused before your route runs. A chunked body is refused the moment the count crosses the cap: the response is 413 Payload Too Large with Connection: close, and if your handler had already started reading, its read fails instead of returning a truncated body. The rest of the body is discarded as it arrives — never held in memory — for at most two seconds after the 413 is sent and at most 16 MiB, so a client that is still uploading can read the 413 rather than a connection reset, and then the connection is closed. On a pipelined connection, any earlier responses are delivered first. A refusal never affects other requests the server is handling, even when your handler never listens for request errors. (If a handler has already begun sending its response before reading the body, a status can no longer be sent, so the connection is dropped instead.)

  • WebSocket upgrades and streamed responses are not affected — the cap applies to request bodies only.

  • The response carries no knext-specific detail. On the standalone build its body is the text Payload Too Large; on the vinext build it is a bare 413 produced by the runtime. To confirm which cap is in force, read the line the app prints on start:

    REQUEST_BYTE_CAP:8388608 (default)                         # standalone build
    REQUEST_BYTE_CAP:8388608 METRICS_BYTE_CAP:65536 (default)  # vinext build

    With the cap removed it reads REQUEST_BYTE_CAP:none (uncapped).

Apps created with knext create on 1.0.0 or earlier have containerConcurrency: 100 in their knext.config.ts — five times the 20 this cap's sizing assumes. At 100, concurrent large uploads can buffer far more request-body bytes than a 1Gi pod's memory limit allows, which can get the pod OOM-killed instead of cleanly answering 413. Either delete that line (the operator's default of 20 then applies) or set it to 20 or lower. A fix so that new apps no longer set this line is planned for 1.0.1.

Change it per app with an environment variable — in knext.config.ts:

knext.config.ts
const config: KnativeNextConfig = {
  name: 'acme',
  env: {
    KNEXT_MAX_REQUEST_BYTES: '20971520', // 20 MiB
  },
};

or directly on the resource:

apiVersion: apps.kn-next.dev/v1alpha1
kind: NextApp
spec:
  env:
    KNEXT_MAX_REQUEST_BYTES: "20971520"   # 20 MiB

If your app accepts uploads larger than 8 MiB through a route handler, raise the cap to your largest expected upload — and consider raising the pod's memory limit with it, since each in-flight upload can occupy up to that much memory while your handler buffers it. For very large files, uploading straight to object storage with a pre-signed URL keeps the bytes out of your app entirely.

KNEXT_MAX_REQUEST_BYTES=0 removes the cap entirely. The app then buffers a body of any size the runtime accepts, and with containerConcurrency above 1 that is an out-of-memory kill rather than a 413. It is deliberate, it is logged loudly on every start, and it should be temporary. Any value that is not a non-negative integer is ignored with a warning and the 8 MiB default stays in force — a typo cannot silently remove the cap. On the vinext build the metrics port stays capped either way.

If you need a smaller, per-route limit — say 100 KB on one JSON endpoint — the platform cap does not replace it. Check the length as you read, rather than trusting the header:

// app/api/upload/route.ts
const MAX_BYTES = 1_000_000;

export async function POST(req: Request) {
  if (!req.body) return new Response('empty body', { status: 400 });

  let total = 0;
  const chunks: Uint8Array[] = [];
  // Count bytes as they arrive and bail early — never buffer first and check after.
  for await (const chunk of req.body as unknown as AsyncIterable<Uint8Array>) {
    total += chunk.byteLength;
    if (total > MAX_BYTES) {
      return new Response('payload too large', { status: 413 });
    }
    chunks.push(chunk);
  }

  const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
  return Response.json({ ok: true, size: total, keys: Object.keys(body).length });
}

3. Set the Server Action limit deliberately

The 1 MB default applies to Server Actions only. Set it explicitly so the value is a decision rather than an inheritance:

Uploading files through a Server Action? Next's Server Action limit applies first, so a 2 MB file is refused by Next long before the 8 MiB request body cap is reached. To accept larger files, raise bodySizeLimit here — and if you raise it above 8 MiB, raise KNEXT_MAX_REQUEST_BYTES to match, or those uploads get a 413 from the platform cap instead.

// next.config.ts
export default {
  experimental: {
    serverActions: { bodySizeLimit: '1mb' },
  },
};

4. Tighten the per-app knobs

apiVersion: apps.kn-next.dev/v1alpha1
kind: NextApp
spec:
  timeoutSeconds: 30            # no long-running streams? drop it from 300
  scaling:
    containerConcurrency: 20    # requests in flight per pod
    maxScale: 10                # the ceiling a flood can reach

Why knext does not ship a proxy

Putting a proxy sidecar next to every app would add a container to the cold-start path, which is the one thing a scale-to-zero platform cannot spend freely. The byte cap above is the in-process alternative, and it costs one integer parse at start — nothing per request, and nothing at all on the cold-start path.

Rate limiting is the piece that is still yours. The platform bounds concurrency, duration, scale and (on the vinext build) bytes; nothing bounds how fast one client may call you, and a proxy is still the right place for that.

On this page