# Transfer-Encoding Header

> Learn how the Transfer-Encoding header specifies encoding formats like chunked transfer for streaming responses when content length is unknown beforehand.

Source: https://howhttpworks.com/headers/transfer-encoding
Last reviewed: 2026-10-05

> **TL;DR:** `Transfer-Encoding: chunked` lets an HTTP/1.1 server start sending a body before it knows the total size. The body goes out in chunks, each prefixed with its length in hexadecimal, and a zero-length chunk marks the end. Your HTTP library writes this framing for you. HTTP/2 and HTTP/3 have their own framing and don't use this header at all.

## What is Transfer-Encoding?

A transfer coding applies to a single hop of the message, not to the resource itself. A proxy can decode the chunks it receives and frame the body differently on the next hop. [Content-Encoding](https://howhttpworks.com/headers/content-encoding), such as gzip, is the other kind: it describes how the representation itself is encoded.

## How Transfer-Encoding Works

Here's an HTTP/1.1 response (wire examples on this page are trimmed), with each `\r\n` written out. Each displayed line is one run of bytes; the line breaks you see aren't on the wire:

```text
HTTP/1.1 200 OK\r\n
Content-Type: text/plain\r\n
Transfer-Encoding: chunked\r\n
\r\n
5\r\n
Hello\r\n
6\r\n
 World\r\n
0\r\n
\r\n
```

The decoded body is `Hello World`, eleven bytes. Each chunk size counts only the data, not the CRLFs around it. The grammar is in [RFC 9112 §7.1](https://www.rfc-editor.org/rfc/rfc9112#section-7.1).

## Syntax

```http
Transfer-Encoding: chunked
Transfer-Encoding: gzip, chunked
```

Codings apply in the order listed, so the second line means gzip first, then chunked. Chunked can only appear once. In a request, chunked has to be the last coding. In a response, if the last coding isn't chunked, the body ends only when the connection closes, which is why `chunked, gzip` can't be used on a connection you want to reuse. See [RFC 9112 §6.1](https://www.rfc-editor.org/rfc/rfc9112#section-6.1).

## Common Examples

For everyday compressed streaming over HTTP/1.1, the compression and the framing sit in separate headers:

```http
Content-Encoding: gzip
Transfer-Encoding: chunked
```

A response without `Transfer-Encoding` isn't necessarily sent in one go. A server that knows the length up front can still write the body a piece at a time.

## Real-World Scenarios

Over HTTP/1.1, chunking lets a server send bytes as soon as it has them. Server-sent events have their own event format on top of that, and SSE events and HTTP chunks don't have to line up.

At the application layer, one event looks like this:

```text
data: {"status":"ready"}


```

Here `\n` stands for a line feed. The blank line ends the SSE event; it has nothing to do with HTTP's zero chunk. One event can span several chunks, and one chunk can hold several events. The browser's [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) parses SSE for you; if you read the stream yourself, you have to split events yourself too.

`EventSource` reconnects automatically when a stream closes. If you point it at the finite demo below, call `source.close()` when you're done, or it will open another connection. The stream reader example further down reads the response once.

## Server Implementation

This complete Node HTTP/1.1 server leaves the framing to the HTTP stack. Write plain content and let Node add the hex size lines:

```javascript
const http = require('node:http')
http.createServer((req, res) => {
  res.setHeader('Content-Type', 'text/plain; charset=utf-8')
  res.write('Hello')
  res.end(' World')
}).listen(3000)
```

[Node's HTTP stack generates the message framing](https://nodejs.org/api/http.html#responsewritechunk-encoding-callback). Two `write()` calls won't necessarily become two packets or arrive at two different moments.

An SSE demo that stops after a few events makes incremental delivery easier to watch. Save it as its own Node script and run it in place of the server above:

```javascript
const http = require('node:http')
http.createServer((req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-store'
  })
  res.flushHeaders()
  let count = 0
  const timer = setInterval(() => {
    res.write(`data: ${JSON.stringify({ count: ++count })}\n\n`)
    if (count === 3) {
      clearInterval(timer)
      res.end()
    }
  }, 1000)
  res.on('close', () => clearInterval(timer))
}).listen(3000)
```

It sends three events, one per second. That interval is the demo's choice; chunked encoding doesn't set any pace. Clearing the timer on `close` stops the work when the client disconnects. [`flushHeaders()`](https://nodejs.org/api/http.html#responseflushheaders) gets the headers out before the first event, but a proxy further down the line can still buffer.

To proxy this server through nginx, add this to a `server` block:

```nginx
location /events {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_cache off;
}
```

[`proxy_buffering`](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering) is `on` by default. Turning it off makes nginx forward upstream data as it arrives, and `proxy_cache off` cancels any caching inherited from a parent block. The app can also opt out per response with `X-Accel-Buffering: no`, unless `proxy_ignore_headers` is set to ignore it. `proxy_http_version` only affects the nginx-to-upstream hop; the browser side negotiates its own protocol.

In Django, [`StreamingHttpResponse`](https://docs.djangoproject.com/en/6.0/ref/request-response/#streaminghttpresponse-objects) streams an iterator, such as CSV rows. This synchronous view is for WSGI:

```python
from django.http import StreamingHttpResponse

def export(request):
    def rows():
        yield 'status\n'
        yield 'ready\n'
    return StreamingHttpResponse(rows(), content_type='text/csv')
```

Your code yields content, and the server's HTTP stack picks the wire framing. Under ASGI, use an async iterator instead. Django documents that if it has to adapt the wrong kind of iterator, it consumes the whole thing first, so nothing streams.

## Getting Transfer-Encoding right

Never send `Content-Length` and `Transfer-Encoding` on the same message. That combination is a classic sign of request smuggling. [RFC 9112 §6.3](https://www.rfc-editor.org/rfc/rfc9112#section-6.3) defines how to handle it, and requires an intermediary that forwards such a message to drop the received `Content-Length` and process the transfer coding first.

HTTP/2 forbids this connection-specific header ([RFC 9113 §8.2.2](https://www.rfc-editor.org/rfc/rfc9113#section-8.2.2)), and so does HTTP/3 ([RFC 9114 §4.1](https://www.rfc-editor.org/rfc/rfc9114#section-4.1)). A proxy can receive chunked HTTP/1.1 on one side and send HTTP/2 DATA frames on the other.

In Node, correct framing doesn't take care of backpressure for you. When [`res.write()`](https://nodejs.org/api/http.html#responsewritechunk-encoding-callback) returns `false`, data is piling up in memory; wait for the `drain` event before you write more. A fast export loop that ignores this can build up a huge backlog for a slow client, with every chunk perfectly framed.

## Chunked Encoding Format

After the zero-size last chunk comes an optional trailer section, ended by an empty line. With no trailers, the body ends in `0\r\n\r\n`. Sizes are hexadecimal: `A` means ten bytes of data, and `10` would mean sixteen.

If the sender stops after `5\r\nHello\r\n` and never sends the last chunk, the message is incomplete, even though five valid bytes arrived. For a chunked message, the connection closing doesn't count as an ending. That's what bites you when a process crashes halfway through an export: the client has some bytes, but the response never completed.

An empty chunk can't work as a heartbeat, because a zero-size chunk ends the body. Send heartbeats as content, such as an SSE comment.

## Common Use Cases

Chunking lets a generated response start before its total size is known. It only provides the framing, though; the app or a proxy can still buffer the stream. Debug framing and buffering as two separate problems.

In the browser, `fetch()` hands you the decoded bytes as a stream. For the finite text stream above:

```javascript
const response = await fetch('/events')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
if (!response.body) throw new Error('No response body')
const reader = response.body.getReader()
const decoder = new TextDecoder()
try {
  while (true) {
    const { value, done } = await reader.read()
    if (done) break
    console.log(decoder.decode(value, { stream: true }))
  }
  console.log(decoder.decode())
} finally {
  reader.releaseLock()
}
```

[`getReader()`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream/getReader) locks the stream while you read it. [`decode(..., { stream: true })`](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder/decode) holds on to a partial multi-byte character until the next read. Each logged string can cut through the middle of a line or an SSE event. If you want whole events as objects, use `EventSource` instead.

## Testing Transfer-Encoding

Against the local Node example:

```bash
curl --http1.1 --raw -i http://localhost:3000/
```

[`--raw`](https://curl.se/docs/manpage.html#--raw) turns off curl's transfer decoding so you can see the chunk framing; without it, curl strips the size lines. Browser Fetch only ever gives you the decoded body, never the raw HTTP/1.1 chunk sizes.

To watch the app's delivery timing without the chunk markers:

```bash
curl --http1.1 -N http://localhost:3000/events
curl --http1.1 -N https://example.com/events
```

[`-N`](https://curl.se/docs/manpage.html#--no-buffer) turns off curl's output buffering. Compare a direct request to the upstream with one through the public route. If events trickle in directly but all arrive at once through the public route, check proxy buffering and compression before you touch the app's writes. Check the negotiated protocol too: on HTTP/2 there's no `Transfer-Encoding` header, which is normal and has nothing to do with a delayed stream.

## Related Headers

- [Content-Length](https://howhttpworks.com/headers/content-length)
- [Content-Encoding](https://howhttpworks.com/headers/content-encoding)
