Get in touch

9io.ai / Blog

Three Amplify Hosting limits our Next.js BFF only hit in production

A 6 MB response cap, an 8 KB URL limit and a localhost origin broke parts of our Next.js BFF on AWS Amplify. What we saw, the fixes, and how to test early.

Key takeaways

  • Amplify Hosting compute caps responses near 6 MB, so compress large JSON in your own code.
  • CloudFront compresses after the origin responds, which is too late to rescue an oversized response.
  • CloudFront answers URLs over 8,192 bytes with a 414, so keep growing lists out of query strings.
  • Behind Amplify, request.nextUrl reports localhost:3000; get the public origin from configuration or trusted headers.
  • Run size, URL-length and origin tests against a preview deployment before every release.

An AI study platform we build and run has a backend-for-frontend (BFF) proxy inside its Next.js app, hosted on AWS Amplify Hosting. Three limits in that setup only appeared in production. JSON responses over about 6 MB came back as a 413 with an empty body. URLs over about 8 KB got a 414 from CloudFront. And every write failed the BFF’s origin check with a 403, because request.nextUrl reported localhost:3000 instead of the public origin.

The fixes were small. The proxy now gzips JSON responses over 512 KB, which took a 9 MB response to about 2 MB. The origin check takes the public origin from forwarded headers or configuration, and never from request.nextUrl. URLs have to stay under CloudFront’s 8,192-byte limit, which means long, growing lists can’t live in query strings.

For each limit, this post covers the symptom and where it comes from, then the fix and a test you can run against a preview deployment before release. Published limits are as of 7 October 2026.

What sits between the browser and your code on Amplify

Locally, the browser talks straight to the Next.js server. On Amplify Hosting, two layers you don’t have locally sit around that server. Amplify serves apps through Amazon CloudFront1, and it runs your Next.js server inside its own compute layer, whose deployment specification requires an HTTP server listening on port 30002. Every request to the BFF passes through both, and each of the three limits lives in one of them. AWS documents the response limit in the Amplify troubleshooting guide3 and the URL limit in CloudFront’s request rules4.

Limit Layer What AWS documents What we saw
Response size Amplify compute 5.72 MB; larger responses return a 504 with no content A 413 with an empty body above about 6 MB
URL length CloudFront 8,192 bytes; longer URLs get a 414 A 414 on long query strings
Request URL seen by Next.js Amplify compute The server listens on port 3000 request.nextUrl on localhost:3000

None of the three shows up in development. Locally there is no CloudFront and no compute layer, so large responses and long URLs go straight through, and the origin the browser sends matches the one Next.js reports.

Responses over about 6 MB came back as an empty 413

The first limit hit the BFF’s largest JSON responses. The browser received a 413 from CloudFront with nothing in the body. What the failing requests had in common was size.

The Amplify troubleshooting page puts the maximum response size for Next.js 12 and later apps on its compute platform at 5.72 MB, and says responses over it “return 504 errors with no content”3. We saw a 413 instead of a 504. Neither code describes the problem well. RFC 9110 defines 413 as request content that is too large5 and 504 as an upstream that didn’t answer in time6, so each one points you somewhere else, at the request body or at a timeout.

The empty body is the better clue. If your route handlers return errors in a format of their own, an empty body means something in front of your code answered. To confirm it, run the same request against the app locally and look at the size of the response. If it’s over about 5.7 MB, you’ve found the cause.

If the number looks familiar, Lambda caps synchronous response payloads at 6 MB7. AWS doesn’t document how Amplify’s compute is built, so design against the Amplify figure.

Compressing in the proxy, because CloudFront compresses too late

The fix was to compress in the proxy. JSON compresses well: a 9 MB response came out at about 2 MB as gzip, comfortably under the limit. The proxy only compresses above 512 KB. Smaller responses take the normal path, and CloudFront can still compress those at the edge if the distribution is set up to.

Edge compression can’t help with the large ones, because CloudFront compresses a response after it gets it from the origin8. By then the full-size body has already left the compute layer, which is where the limit applies. When a response from the origin already carries a Content-Encoding header, CloudFront sends it on as it is8, so compressing in the proxy and compressing at the edge don’t conflict.

Next.js has its own compress option, which gzips responses under next start or a custom server9. Whether it runs on a managed host depends on how that host starts your server and which headers reach it. Compressing in your own handler makes the size of what leaves the compute layer something you decide. A sketch for a route handler:

// Compress large JSON before it leaves the compute layer.
import { gzip } from 'node:zlib'
import { promisify } from 'node:util'

const gzipAsync = promisify(gzip)
const GZIP_ABOVE_BYTES = 512 * 1024

export async function jsonResponse(req: Request, json: string, status = 200): Promise<Response> {
  const body = Buffer.from(json)
  const headers = new Headers({
    'content-type': 'application/json; charset=utf-8',
    vary: 'accept-encoding',
  })

  // A request with no Accept-Encoding header accepts any coding (RFC 9110, 12.5.3).
  // q-values are ignored here for brevity.
  const accepted = req.headers.get('accept-encoding')
  const gzipOk = accepted === null || /\bgzip\b/i.test(accepted)

  if (body.byteLength <= GZIP_ABOVE_BYTES || !gzipOk) {
    return new Response(body, { status, headers })
  }

  headers.set('content-encoding', 'gzip')
  return new Response(await gzipAsync(body), { status, headers })
}

The Accept-Encoding check follows RFC 9110, where a request without the header accepts any content coding10. That matters if something in front of your server drops the header on the way in. Every current browser sends it.

Compression moves the limit out by about the compression ratio, roughly four and a half times for that 9 MB response. A response that keeps growing will reach the new ceiling eventually, and the lasting fixes are pagination or a smaller response shape. Log the compressed size of large responses so you see that coming.

URLs longer than 8,192 bytes get a 414 from CloudFront

The second limit is in CloudFront. It builds a URL from each request, and that URL can be at most 8,192 bytes. Longer ones get a 414 URI Too Long, and CloudFront then closes the connection4. A related limit caps the path, query string and headers together at 32,768 bytes, and going over it returns a 4944. Cookies count toward that one.

We hit the 414 with long query strings. A query string that grows with what a user selects can cross the limit without anyone noticing. Percent-encoding makes it worse, because every byte it encodes becomes three characters11, so the URL on the wire can be much longer than the values that went into it.

The request never reaches your code, so your server logs have nothing to show. Locally, the same URLs went straight to Node and worked.

RFC 9110 calls 414 a rare condition, most likely when a client has turned a POST into a GET with long query information12, and turning it back is the first of three ways to stay under the limit. In the order we’d reach for them:

  • Send long parameter lists in a POST body. A search or filter endpoint that takes JSON has no URL problem. The trade-off is caching, because CloudFront only caches responses to GET requests.
  • Put less in the URL. Short IDs instead of names, and one parameter holding a compact list instead of a repeated key per value.
  • Store long state on the server and pass a short key to it in the URL. This keeps URLs shareable, at the cost of a lookup.

Whichever you pick, give the code that builds URLs a byte budget, so an oversized URL fails in a test instead of at CloudFront:

// CloudFront allows 8,192 bytes for the whole URL; leave room for the scheme, host and path.
const QUERY_BUDGET_BYTES = 6_000

export function withQuery(path: string, params: URLSearchParams): string {
  const query = params.toString() // percent-encoded, as it will be sent
  const bytes = new TextEncoder().encode(query).length
  if (bytes > QUERY_BUDGET_BYTES) {
    throw new Error(`query string is ${bytes} bytes; send these parameters in a request body`)
  }
  return query ? `${path}?${query}` : path
}

request.nextUrl reported localhost:3000, so every write got a 403

The BFF checks the Origin header on every write. Browsers send Origin on same-origin POST, PUT, PATCH and DELETE requests[^mdn-origin], and comparing it with the app’s own origin is a standard defence against cross-site request forgery13. The check took the app’s own origin from request.nextUrl, the parsed request URL that Next.js puts on NextRequest14.

In production, request.nextUrl reported localhost:3000, the address of the Node server inside Amplify’s compute layer. The browser’s Origin was the public domain. The two never matched, so the check rejected every write with a 403. Reads were unaffected, because the check only runs on writes.

The cause is in how Next.js builds that URL. By default it doesn’t use the Host header. In the Next.js source, the absolute URL is assembled from the hostname and port the server was started with15, and Amplify’s compute expects your server on port 30002. On other hosts the same behaviour shows up as 0.0.0.0:300016. Locally, Next.js really is at localhost:3000, which is also the origin of the page in the browser, so the check passes on every laptop.

The quickest way to see it is to log the Origin header next to the origin the check expected, on every rejected write. The same problem affects anything else built from request.nextUrl, such as absolute redirect targets or links in outgoing email.

Getting the public origin from somewhere you trust

Our check now takes the public origin from the forwarded headers or from configuration, and never from request.nextUrl. OWASP’s CSRF cheat sheet describes this exact situation, an application server behind a proxy that receives a different URL from the one the browser used13. It lists three ways to find the right origin and is clear about which to trust:

  • A configured origin is the most secure, because it is defined on the server.
  • The Host header is usually rewritten by the proxy, so behind a CDN it often won’t match the browser’s Origin.
  • X-Forwarded-Host is usable only when the proxy in front of you removes or overwrites whatever the client sent.

Configuration has a cost on Amplify. Pull request previews get their own URLs[^amp-previews], so a fixed value needs a setting per environment. If you use forwarded headers instead, check what your platform actually sends by logging them from a preview deployment, and confirm it overwrites client-supplied values before you rely on them.

Next.js makes the same comparison for Server Actions, checking Origin against Host or X-Forwarded-Host, and it offers an allowedOrigins setting for apps behind proxies17. A configured check for a route handler can be this small:

// Allowed public origins for this environment, from configuration.
import { publicOrigins } from '@/config'

export function isAllowedWrite(req: Request): boolean {
  const origin = req.headers.get('origin')
  // Browsers send Origin on writes. A missing or "null" origin fails the check.
  if (!origin || origin === 'null') return false
  return publicOrigins.includes(origin)
}

OWASP also warns against allowlisting the literal value null, which sandboxed and opaque contexts can send13. The sketch treats it as a failure.

Testing for all three before production

None of these limits exists on a laptop, so test where they do. Amplify can deploy each pull request to its own preview URL[^amp-previews], on the same hosting platform as production. A few curl calls against a preview cover the basics:

# Size: the largest real response should return 200, compressed
curl -s -o /dev/null -H 'Accept-Encoding: gzip' -w '%{http_code} %{size_download} bytes\n' \
  "https://preview.example.com/api/your-largest-response"

# URL length: just over 8,192 bytes should get CloudFront's 414
curl -s -o /dev/null -w '%{http_code}\n' \
  "https://preview.example.com/api/anything?q=$(python3 -c 'print("a" * 8300)')"

# Origin: a same-origin write must get past the origin check (an auth error is fine here)
curl -s -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' \
  -H 'Origin: https://preview.example.com' -d '{}' "https://preview.example.com/api/some-write"

The second call confirms the limit exists. The byte budget in your URL builder is what keeps real URLs under it. For the origin check, the test that matters most is an end-to-end write, signed in as a test user, against the preview URL. A unit test with a mocked request can’t catch this, because the mock carries whatever origin you give it.

How we measured

The 9 MB and 2 MB figures are the size of one JSON response before and after gzip in the proxy. Compression ratios depend on the data, so measure your own largest responses. “About 6 MB” and “about 8 KB” are the points where we saw failures, rounded. The exact published values are in the table near the top, with their sources.

This post has no latency or CPU figures for the compression step. The limits themselves are specific to Amplify Hosting and CloudFront. Other managed hosts publish their own, so look those up rather than assuming these carry over.

A checklist for a Next.js BFF on managed hosting

  • Write down your platform’s response-size, URL-length and request-size limits before launch, with the date you checked them.
  • Compress large JSON in your own code, and log the size of what leaves the server so growth shows up before it fails.
  • Treat an empty-bodied 4xx or 5xx on a large response as a platform limit until you’ve ruled that out.
  • Keep variable-length data out of URLs, and give the code that builds them a byte budget with a test.
  • Never derive the public origin from request.nextUrl or the Host header behind a CDN. Use configuration, or forwarded headers your platform overwrites.
  • Run the size, URL-length and origin tests against a preview deployment before each release, including a signed-in write.

If the same app server-renders public pages, read its HTML as well. How PersistGate emptied our Next.js server HTML covers a failure that every check running JavaScript missed.


  1. AWS Amplify Hosting User Guide, Managing cache configuration, https://docs.aws.amazon.com/amplify/latest/userguide/caching.html ↩

  2. AWS Amplify Hosting User Guide, Deployment specification, https://docs.aws.amazon.com/amplify/latest/userguide/ssr-deployment-specification.html ↩↩

  3. AWS Amplify Hosting User Guide, Troubleshooting server-side rendered applications (HTTP response size), https://docs.aws.amazon.com/amplify/latest/userguide/troubleshooting-SSR.html#http-response-size-too-large ↩↩

  4. Amazon CloudFront Developer Guide, Request and response behavior for custom origins (maximum length of a request and of a URL), https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/RequestAndResponseBehaviorCustomOrigin.html#RequestCustomMaxRequestStringLength ↩↩↩

  5. RFC 9110, HTTP Semantics, section 15.5.14 (413 Content Too Large), https://www.rfc-editor.org/rfc/rfc9110#section-15.5.14 ↩

  6. RFC 9110, HTTP Semantics, section 15.6.5 (504 Gateway Timeout), https://www.rfc-editor.org/rfc/rfc9110#section-15.6.5 ↩

  7. AWS Lambda Developer Guide, Lambda quotas, https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html ↩

  8. Amazon CloudFront Developer Guide, Serve compressed files, https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/ServingCompressedFiles.html ↩↩

  9. Next.js, compress, https://nextjs.org/docs/app/api-reference/config/next-config-js/compress ↩

  10. RFC 9110, HTTP Semantics, section 12.5.3 (Accept-Encoding), https://www.rfc-editor.org/rfc/rfc9110#section-12.5.3 ↩

  11. RFC 3986, URI Generic Syntax, section 2.1 (percent-encoding), https://www.rfc-editor.org/rfc/rfc3986#section-2.1 ↩

  12. RFC 9110, HTTP Semantics, section 15.5.15 (414 URI Too Long), https://www.rfc-editor.org/rfc/rfc9110#section-15.5.15[^mdn-origin]: MDN, Origin header, https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Origin ↩

  13. OWASP, Cross-Site Request Forgery Prevention Cheat Sheet (Identifying the target origin), https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#identifying-the-target-origin ↩↩↩

  14. Next.js, NextRequest, https://nextjs.org/docs/app/api-reference/functions/next-request ↩

  15. Next.js source, resolve-routes.ts at v16.4.0, lines 175 to 182, https://github.com/vercel/next.js/blob/v16.4.0/packages/next/src/server/lib/router-utils/resolve-routes.ts#L175-L182 ↩

  16. vercel/next.js issue #79182, Request URL is incorrect, https://github.com/vercel/next.js/issues/79182[^amp-previews]: AWS Amplify Hosting User Guide, Web previews for pull requests, https://docs.aws.amazon.com/amplify/latest/userguide/pr-previews.html ↩

  17. Next.js, How to think about data security in Next.js (Allowed origins), https://nextjs.org/docs/app/guides/data-security#allowed-origins-advanced ↩

Frequently asked questions

What is the maximum response size for a Next.js app on AWS Amplify Hosting?

AWS documents 5.72 MB for Next.js 12 and later on Amplify’s compute platform, and says larger responses return a 504 with no content. We saw failures above about 6 MB arrive as a 413 with an empty body, so test by size and expect either status.

What is the maximum URL length on CloudFront?

8,192 bytes for the URL CloudFront builds from a request. A longer URL gets a 414 URI Too Long. The path, query string and headers together are capped at 32,768 bytes, and going over that returns a 494.

Why does request.nextUrl show localhost:3000 in production?

Next.js builds the request URL from the hostname and port its own server was started with, and Amplify runs that server on port 3000 behind CloudFront. Take the public origin from configuration, or from forwarded headers that your platform overwrites.

Does CloudFront compression help with Amplify’s response size limit?

No. CloudFront compresses a response after it receives it from the origin, so an oversized body has already failed by then. Compress in the server code that produces the response.

How can I test hosting limits before production?

Deploy a preview on the same platform and send it your largest real response, a URL just over 8,192 bytes and a write from the preview’s own origin. A local development server has none of these limits, so it can’t tell you.

Work with us

Building something like this?

9io is a small team of senior engineers with a fractional CTO, and we work by the hour. Send us a note about your product. The reply comes from the person who'd do the work.