# ERR_INCOMPLETE_CHUNKED_ENCODING: Fix Truncated Responses

> Fix ERR_INCOMPLETE_CHUNKED_ENCODING by finding missing final chunks, upstream crashes, nginx read timeouts, buffering failures and stalled SSE streams.

Source: https://howhttpworks.com/debug/err-incomplete-chunked-encoding
Last reviewed: 2026-10-05

Error messages this page covers:
- `net::ERR_INCOMPLETE_CHUNKED_ENCODING`
- `ERR_INCOMPLETE_CHUNKED_ENCODING`
- `curl: (18) transfer closed with outstanding read data remaining`
- `ECONNRESET: aborted`
- `IncompleteRead(5 bytes read)`
- `upstream prematurely closed connection`
- `upstream timed out`

> **TL;DR:** The connection closed before the response body finished. In HTTP/1.1 chunked encoding, a body is complete only when the final zero-length chunk arrives, so you can see `200 OK` and still get this error. Run `curl --http1.1 -v --no-buffer` against the failing route, then again directly against the upstream. If only the public route cuts off, look at proxy timeouts and buffering. If both cut off, the producer is crashing or stalling: read its logs.

## What it means

[Chromium's `ERR_INCOMPLETE_CHUNKED_ENCODING` (-355)](https://chromium.googlesource.com/chromium/src/+/main/net/base/net_error_list.h) fires when the connection closes before the terminating zero-length chunk arrives. HTTP/1.1 [chunked transfer coding](https://howhttpworks.com/headers/transfer-encoding) sends the body as a series of chunks, each a hexadecimal byte count followed by that many bytes of data. [RFC 9112 Sections 7.1 and 8](https://www.rfc-editor.org/rfc/rfc9112.html#section-7.1) define how the stream ends and say a response that stops early must be recorded as incomplete.

Wire bodies and outputs on this page are trimmed examples. Here `\r\n` stands in for CRLF; the body carries the five bytes `hello` and ends properly:

```text
5\r\nhello\r\n0\r\n\r\n
```

If the server closes after `5\r\nhello\r\n`, the response is incomplete. `Connection: close` doesn't stand in for the final chunk. Trailers, if any, go after the zero-size chunk and end with a blank line.

Serving that cut-off response from a loopback test server, **curl 8.7.1 on macOS** printed:

```text
curl: (18) transfer closed with outstanding read data remaining
```

That exact wording comes from the chunk decoder in [curl 8.7.1](https://github.com/curl/curl/blob/curl-8_7_1/lib/http_chunks.c), and it's still there in [8.22.0](https://github.com/curl/curl/blob/curl-8_22_0/lib/http_chunks.c). Exit code 18 on its own is broader, though: it means any partial transfer, chunked or not.

The same test server made **Node 26.10.0** `node:http` emit `ECONNRESET: aborted` on the response's error event, and made **Python 3.14.8** `http.client` raise `IncompleteRead(5 bytes read)` when reading the whole body. Python's byte count depends on how much arrived. Node uses the same message for other early closes, so check the HTTP framing before you blame a missing chunk.

HTTP/2 has its own framing and forbids `Transfer-Encoding` entirely ([RFC 9113 Section 8.2.2](https://www.rfc-editor.org/rfc/rfc9113.html#section-8.2.2)). The commands below force HTTP/1.1 to compare hops, which means they may take a different proxy path from the one the browser negotiated.

## Find the hop that stopped sending

```bash
curl --http1.1 -v --no-buffer 'https://example.com/stream'
# On the origin host, bypass the reverse proxy:
curl --http1.1 -v --no-buffer -H 'Host: example.com' \
  'http://127.0.0.1:3000/stream'
```

Match the failing request's method, authorization and body. [`--no-buffer`](https://curl.se/docs/manpage.html#--no-buffer) turns off curl's own output buffering, but the server still decides when to flush. Watch when data last arrived and how long the line was silent before the close.

For a **finite** response, save the raw HTTP/1.1 chunk framing with content decoding off:

```bash
curl --http1.1 --raw -sS -D response.headers \
  -H 'Accept-Encoding: identity' -o chunks.raw \
  'https://example.com/finite-response'
xxd chunks.raw | tail
```

Confirm `Transfer-Encoding: chunked`, walk the chunk boundaries and look for the final zero chunk. A literal `0` inside the data isn't a terminator. Let the transfer run to the end: piping through `head` or setting a client time limit truncates the response yourself, and then you're debugging your own cut-off. An SSE stream that stays open has no final chunk until it ends normally, so use the live comparison above for streams.

## Fix it, in diagnostic order

### 1. The upstream died after starting the body

Line up the failure time with app exceptions, worker exits, restarts and request IDs:

```bash
docker logs --since 10m my-app
docker inspect --format '{{json .State}}' my-app
sudo tail -n 100 /var/log/nginx/error.log
```

nginx's [upstream source](https://github.com/nginx/nginx/blob/master/src/http/ngx_http_upstream.c) logs `upstream prematurely closed connection` when the upstream ends the response early. Read the rest of the log line. **While reading response headers** means it failed before the body started. A failure mid-body is the case that hands the client headers plus partial data.

Fix whatever killed the producer: the exception, the killed process, the interrupted stream. When the response finishes normally, end it with your framework's response-ending API so the library writes the terminator. Writing `0\r\n\r\n` yourself as application data into a response the HTTP library is already chunking corrupts the body instead of ending it.

### 2. A silent gap exceeded a proxy or load-balancer timeout

```bash
sudo nginx -T 2>&1 | grep -nE 'proxy_read_timeout|proxy_buffering|proxy_ignore_headers'
sudo rg -n 'upstream timed out|upstream prematurely closed' /var/log/nginx/error.log
aws elbv2 describe-load-balancer-attributes --load-balancer-arn "$ALB_ARN"
```

Keep the `nginx -T` dump on the box, since it can contain deployment details. [`proxy_read_timeout`](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_read_timeout) defaults to **60 seconds between upstream reads**. It's not a cap on the whole response; it's how long the upstream may go quiet. If that happens before headers, you get a [504](https://howhttpworks.com/debug/nginx-504-gateway-timeout). After the response has started, the client gets a truncated body instead.

The [AWS Application Load Balancer idle timeout](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/edit-load-balancer-attributes.html) also defaults to **60 seconds**, but it's a separate timer on idle client or target connections, and HTTP/2 PING frames don't reset it. Measure the longest silent gap and compare it with the timeout on every hop.

For SSE or an LLM token stream, have nginx pass each upstream write straight through. In this route config, **300 seconds is a chosen allowance**, not a default:

```nginx
location /stream {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_read_timeout 300s;
    proxy_set_header Accept-Encoding identity;
    gzip off;
}
```

Run `nginx -t` before reloading. The config turns off nginx's compression on this route and asks the upstream for unencoded output, which the app has to honor. The load balancer's timer is separate and stays as it was.

Or let the upstream opt out of buffering itself by sending these SSE response fields before the body:

```http
Content-Type: text/event-stream
X-Accel-Buffering: no
```

nginx [honors `X-Accel-Buffering: no`](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering) unless it's configured to ignore it. It has to come from the upstream; adding it with a downstream `add_header` tells nginx's upstream buffer nothing. And `proxy_request_buffering` is about the incoming request body, so it won't fix response buffering.

During pauses in token generation, send and flush SSE comment heartbeats. The [HTML Standard](https://html.spec.whatwg.org/multipage/server-sent-events.html#authoring-notes) suggests one roughly every **15 seconds** to keep legacy proxies from timing out. Pick an interval shorter than the smallest idle timeout on your path, and confirm the heartbeats actually reach the public endpoint. The heartbeat bytes are just:

```text
: keepalive\n\n
```

Drive heartbeats from their own timer so they keep flowing when generation stalls. A heartbeat stuck in the app's or compressor's buffer keeps nothing alive downstream.

### 3. nginx cannot write its buffered response to disk

With [proxy buffering enabled](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering), a response too big for the memory buffers spills into `proxy_temp_path`. If that temp-file write fails, the [event pipe aborts](https://github.com/nginx/nginx/blob/master/src/event/ngx_event_pipe.c), and the [file-write log message](https://github.com/nginx/nginx/blob/master/src/os/unix/ngx_files.c) names the path and OS error. So a full disk can genuinely truncate responses. Look for that log line before blaming disk space for every truncation.

```bash
sudo nginx -T 2>&1 | grep -nE 'proxy_temp_path|proxy_max_temp_file_size|proxy_buffering'
sudo rg -n 'No space left on device|Permission denied|pwrite|writev' /var/log/nginx/error.log
# Replace with the actual proxy temp directory:
df -h /var/lib/nginx/proxy
df -i /var/lib/nginx/proxy
ls -ld /var/lib/nginx/proxy
```

Find the failing path, then check free space, free inodes and whether nginx workers can write there. Fix the capacity or permissions. On a streaming route, the `proxy_buffering off` config above skips this buffering path altogether, though an upstream crash still needs fixing at the upstream.

### 4. Compression buffered the stream or never finished

```bash
curl --http1.1 -v --no-buffer -H 'Accept-Encoding: identity' \
  'https://example.com/stream'
curl --http1.1 -v --no-buffer --compressed 'https://example.com/stream'
```

Compare when heartbeats arrive in each run, and what `Content-Encoding` comes back. gzip and chunked framing work fine together. The catch is that a compressor holds data until it has enough to emit, as [Node's zlib documentation](https://nodejs.org/api/zlib.html#flushing) explains, so call its flush API whenever an event must reach the client now. On normal completion, finish the compressed stream before ending the HTTP response. Turning gzip off for the diagnostic route takes one buffering layer out of the picture. If the chunk framing completes but the compressed bytes are corrupt, that's a different error: see [content decoding failed](https://howhttpworks.com/debug/err-content-decoding-failed).

### 5. An inspecting proxy buffered or interrupted delivery

Try the same request from another network you're allowed to use, then test any explicitly configured proxy on its own:

```bash
curl --noproxy '*' --http1.1 -v --no-buffer 'https://example.com/stream'
curl --proxy http://proxy.example:8080 --http1.1 -v --no-buffer \
  'https://example.com/stream'
```

`--noproxy '*'` skips curl's configured proxy, but a transparent gateway or endpoint antivirus sits in the path regardless. As one example, [Fortinet documents whole-file buffering before proxy antivirus scanning in FortiOS 7.0.5](https://docs.fortinet.com/document/fortigate/7.0.5/administration-guide/5347/protocol-options). That shows such products buffer; it doesn't show one caused your Chrome error. If only the inspected path fails, ask the network operator to check that product's buffering, timeout and scan logs for your request, and to make a route-specific change the product supports.

## Related

- [Transfer-Encoding](https://howhttpworks.com/headers/transfer-encoding)
- [nginx 504 Gateway Timeout](https://howhttpworks.com/debug/nginx-504-gateway-timeout)
- [SSE vs WebSockets](https://howhttpworks.com/compare/sse-vs-websockets)
- [Content decoding failed](https://howhttpworks.com/debug/err-content-decoding-failed)
