Skip to content

[Bug]: theme dev proxy 502s on cookie-heavy storefront responses (undici default 16 KiB maxHeaderSize) #8717

Description

@dangayle

[Bug]: theme dev proxy 502s on cookie-heavy storefront responses (undici default 16 KiB maxHeaderSize)

Description

shopify theme dev renders all pages as 502 ("Failed to render storefront") once the browser has cookies for the store. The storefront proxy dies parsing the upstream response headers: the response to a cookie'd request carries enough Set-Cookie headers to exceed undici's default 16 KiB header limit. The undici error (UND_ERR_HEADERS_OVERFLOW) is swallowed and surfaced as a bare TypeError: fetch failed → 502.

The theme, the dev theme, and the store are all fine — a cookie-less request to the same URL renders correctly.

Steps to reproduce

  1. shopify theme pull --live and shopify theme dev on any store whose storefront sets a large Set-Cookie collection on cookie'd requests (this merchant: a Plus cosmetics store with cart/localization/tracking cookies).
  2. Visit http://127.0.0.1:9292/ in a browser with an existing cookie jar for the store.
  3. Every page render fails:
Failed to render storefront with status 502 (Bad Gateway).
URL: https://REDACTED-store.myshopify.com/?_fd=0&pb=0

TypeError: fetch failed
    at Object.processResponse (node:internal/deps/undici/undici:12793:20)
    at async ze (.../@shopify/cli/dist/chunk-CSNXCRPY.js:12:375)
  1. Open the same URL in an incognito window (empty cookie jar) — it renders. curl to the same URL renders. Node fetch from a fresh process renders. Only the CLI's cookie-forwarding proxy path fails.

Isolating the real error

The CLI wraps the undici error and drops error.cause. Attaching this shim via NODE_OPTIONS="--require /tmp/shim.cjs" and re-running theme dev reveals the cause:

const orig = globalThis.fetch;
globalThis.fetch = async (...args) => {
  try { return await orig(...args); }
  catch (e) {
    console.error('[fetch-debug] URL:', String(args[0] && (args[0].url || args[0])).slice(0, 200));
    console.error('[fetch-debug] cause code:', e.cause && e.cause.code, '| cause:', e.cause && e.cause.message);
    throw e;
  }
};

Output:

[fetch-debug] URL: https://REDACTED-store.myshopify.com/?_fd=0&pb=0
[fetch-debug] cause code: UND_ERR_HEADERS_OVERFLOW | cause: Headers Overflow Error
    at Parser.trackHeader (node:internal/deps/undici/undici:7385:37)
    at Parser.onHeaderValue (node:internal/deps/undici/undici:7376:14)

Supporting measurements

  • Cookie-less GET https://REDACTED-store.myshopify.com/?_fd=0&pb=0 (with the CLI's User-Agent): 200, full 940 KB body, response header block ≈ 7.9 KB — under the 16 KiB cap, no failure.
  • Same request with the browser's cookie jar forwarded: the storefront returns a much larger Set-Cookie collection, pushing the response header block past 16 KiB → UND_ERR_HEADERS_OVERFLOW.
  • GET and POST (with replace_templates form bodies, up to 2 MB) both succeed without cookies, so neither method, body, nor header shape is the trigger — only response header size.

Suggested fix

  1. Configure the proxy's fetch/Agent with a larger maxHeaderSize (e.g. 64–128 KiB) instead of undici's 16 KiB default. Shopify storefronts legitimately set large Set-Cookie collections on cookie'd requests.
  2. In the 502 error page, surface error.cause.code (e.g. UND_ERR_HEADERS_OVERFLOW) instead of only the wrapped TypeError: fetch failed stack — it took a NODE_OPTIONS shim to find the actual cause.

Workaround

Use an incognito/private window for http://127.0.0.1:9292/, or clear site data for 127.0.0.1 and the store domain.

Environment

CLI version @shopify/[email protected] (pnpm global)
Node v24.15.0
OS macOS 15
Store Shopify Plus storefront

Possibly related: #8480 (theme dev proxy 502, cause not isolated — same proxy path, this report's cause code may explain it) and #3963.

Activity

  1. dangayle commented on Oct 1, 2026

    @dangayle
    Author

    Update: measured the actual thresholds and mapped every header-size limit in the CLI

    Follow-up investigation on @shopify/[email protected] / Node v24.15.0 corrects the 16 KiB figure above and adds the floor map.

    1. The effective response-header cap is not 16 KiB on Node 24

    Binary-searched with a synthetic local server (padded Set-Cookie-shaped headers):

    Response header block Result
    31.8 KB (193 headers) passes
    40,313 bytes passes
    40,625 bytes fails
    40 KiB+ fails

    The documented undici default is 16,384 bytes, but Node's bundled undici copy drifts from the package docs, and the failing storefront response in this repro exceeds ~40 KB, not merely 16 KiB. The practical point: the CLI's header cap is whatever Node's bundled parser defaults to that week — Node has moved this default before (8 KiB → 16 KiB historically), so the CLI's behavior changes silently with every Node release under it.

    2. The CLI authors no header limit of its own

    Grepped the entire dist/ bundle: zero maxHeaderSize in CLI code, zero new Agent(, zero setGlobalDispatcher. The only maxHeaderSize in the bundle is a vendored got library, off the theme-dev proxy path. Every limit on the proxy path is inherited:

    1. CLI → storefront, response headers — undici global fetch default (the limit hit in this issue).
    2. Browser → CLI, request headers — the dev server is a plain Node http server with its own default request-header cap. The proxy rewrites the storefront's Set-Cookie onto the 127.0.0.1 origin, so the browser's localhost cookie jar accumulates Shopify cookies; once the request Cookie header to 127.0.0.1:9292 crosses the server-side cap, the proxy dies on the request side instead. Same bug family, other hop — a fix that only raises the undici cap will resurface here.
    3. Proxy-env path — with HTTPS_PROXY/HTTP_PROXY set, the CLI routes through http-proxy-agent/https-proxy-agent (Node http client parser), same inherited defaults again.

    3. Suggested fix, refined

    • Pin both caps explicitly in the CLI instead of inheriting Node's drifting defaults: an undici Agent({ maxHeaderSize }) (or equivalent) for the upstream fetch and server.maxHeaderSize on the local dev server.
    • 128 KiB is the defensible number: it matches the largest mainstream CDN ceiling (Cloudflare raised its total header limit to 128 KB in Oct 2025), covers the measured failing case with ~3x headroom, and llhttp's cap is an upper-bound check, not a preallocated buffer — there is no offsetting cost.
    • Surface error.cause.code in the 502 error page; it took a NODE_OPTIONS fetch shim to find UND_ERR_HEADERS_OVERFLOW under the wrapped TypeError: fetch failed.

    (--max-http-header-size via NODE_OPTIONS was also tested as a possible workaround: it does not raise the bundled undici fetch's cap on Node 24.15.0 — 96 KB and 128 KB header blocks still failed with the flag set.)

  2. dangayle commented on Oct 1, 2026

    @dangayle
    Author

    Follow-up: the fix is version-proof if it uses the explicit option, not the flag

    Checked against the CLI's supported Node range and the Node/undici option history:

    • @shopify/[email protected] declares engines: { node: ">=22.12.0" } — every Node version the CLI supports has both relevant options.
    • http.createServer({ maxHeaderSize }) — supported since Node v13.3.0 (per the v22 LTS docs). Covers the browser→CLI hop on every supported version.
    • undici Agent({ maxHeaderSize }) — the option predates every undici copy Node 22/23/24 bundle. Covers the CLI→storefront hop.

    An explicit value replaces each version's default, so behavior becomes one number on every supported and future Node release — which matters because the default demonstrably drifts: Node 22 docs say 16 KiB, this repro's Node 24.15.0 measured ~40 KB effective, older Node documented 8 KiB.

    One asymmetry for whoever picks this up: the --max-http-header-size flag is not the version-proof route. It did not raise the bundled undici fetch's cap on Node 24.15.0 in testing (96 KB and 128 KB header blocks still failed with the flag set), even though the standalone undici package documents reading that flag. Fixing this by launching a process with the flag instead of passing the option in code just trades one drifting default for another. The durable fix is the explicit option, on both hops.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions