DEV Community

Cover image for I Asked for 499 Bytes, 51,101 Came Back
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

I Asked for 499 Bytes, 51,101 Came Back

This block sits in my blog's nginx configuration:

location /_astro/ {
    proxy_pass http://127.0.0.1:3040;
    expires 1y;
    add_header Cache-Control "public, immutable";
}
Enter fullscreen mode Exit fullscreen mode

So even the site's static assets don't come off disk — they pass through the Node
process, and nginx merely carries them. I knew that, and still, reading the
config today, a question came up that I couldn't answer: if a client asks for
only part of that file, who answers?

There are three candidates — Node, nginx, and Cloudflare out front — and the
request touches all of them. Asking with curl took five seconds. The table
that came out wasn't what I expected: my own stack answers this question with a
single voice, while the edge in front of it runs a different rulebook
altogether. The most expensive answer the edge gave was this one — I asked for
499 bytes and 51,101 came back.

That this piece opens on proxy_pass is no accident: over the past three weeks
my measurements of
open_file_cache
and gzip_static
both snagged on the same line. This is the third. What snags this time isn't a
tuning knob, it's a defense nginx added this year.

First measurement: the page doesn't do ranges, the asset does

Two requests, same site. The first is an article page, the second is the CSS that
page loads. I asked both for their first 100 bytes.

# the article page
curl -s -o /dev/null -w '%{http_code}/%{size_download}\n' \
  -H 'Accept-Encoding: identity' -H 'Range: bytes=0-99' \
  https://mustafaerbay.com.tr/blog/tutorials/node-u-tek-dizine-kilitledim-bes-kapi-acik-kaldi/
# 200/269938

# that page's CSS
curl -s -o /dev/null -w '%{http_code}/%{size_download}\n' \
  -H 'Range: bytes=0-99' \
  https://mustafaerbay.com.tr/_astro/BaseLayout.CGi080bn.css
# 206/100
Enter fullscreen mode Exit fullscreen mode

The page answered my 100-byte request with 269,938 bytes — 2,699 times what I
asked for. The CSS sent exactly what I asked: 100 bytes, with a Content-Range:
bytes 0-99/114436
header beside it. Same server, same certificate, same second:
one of them has no notion of ranges, the other does.

The difference shows up in the headers too. The CSS response carries
Accept-Ranges: bytes; the page response doesn't. HTTP is lenient here — a
server may ignore the Range header field, and RFC 9110 says so explicitly. So
my page is standards-compliant. What interested me is why it behaves that way.

One missing header explains the page

The answer is inside my own server. On the VPS I asked the Node process directly:

curl -s -D- -o /dev/null -H 'Range: bytes=0-99' \
  'http://127.0.0.1:3040/blog/tutorials/node-u-tek-dizine-kilitledim-bes-kapi-acik-kaldi/'
# HTTP/1.1 200 OK
# Transfer-Encoding: chunked
Enter fullscreen mode Exit fullscreen mode

Astro's SSR output streams the page: the body length isn't known when the
response starts, so there is no Content-Length and there is
Transfer-Encoding: chunked. nginx's range filter, for its part, wants three
things before it does anything — a 200 status, a known content_length_n, and
the allow_ranges flag. The filter's entry
condition

hands the response straight on when the length is unknown.

I wanted to verify that in a lab too, because I didn't want to write "because of
a missing header" without measuring it. I ran nginx 1.31.8 in a container on my
Mac and put a tiny two-ended Python server behind it: the /chunked path streams
in chunks, the /cl path serves the same body with a Content-Length. As a
fourth variable I also tried the proxy_force_ranges directive.

path upstream proxy_force_ranges result
/p-chunked/ chunked off 200, whole body
/p-cl/ Content-Length off 200, whole body
/p-chunked-force/ chunked on 200, whole body
/p-cl-force/ Content-Length on 206, 100 bytes

Four rows, one summary: nginx does not slice a proxied response by itself. It
takes two conditions together — the upstream saying Accept-Ranges (or you
writing proxy_force_ranges on) and a Content-Length on the response.
Without the length, the directive doesn't save you either. My
pages ignoring ranges isn't a misconfiguration, it's the price of streaming. I
think that price is fair: no real client asks for 100 bytes from the middle of a
page.

Multiple ranges: the edge manufactures multipart

Things get strange not with one range but with many. HTTP lets a client ask for
several pieces in a single request, and the server answers in
multipart/byteranges form, wrapping each piece in its own little header.

curl -s -D- -o /dev/null -H 'Range: bytes=0-9,20-29,40-49' \
  https://mustafaerbay.com.tr/_astro/BaseLayout.CGi080bn.css
# HTTP/2 206
# content-type: multipart/byteranges; boundary=a331107a9f3d76fe
# content-length: 346
Enter fullscreen mode Exit fullscreen mode

I asked for 30 bytes and 346 came back. The surplus isn't data, it's envelope:
each piece gets a boundary string, a Content-Type and a Content-Range line in
front of it. At this scale nobody cares. But the envelope has a fixed length
while the requested piece doesn't — so the ratio falls apart as you shrink the
pieces and raise their count. So I broke it: I asked for 499 separate bytes in
one request.

R=$(python3 -c "print(','.join(f'{i*7}-{i*7}' for i in range(499)))")
curl -s -o /dev/null -w '%{http_code}/%{size_download}\n' \
  -H "Range: bytes=$R" https://mustafaerbay.com.tr/_astro/BaseLayout.CGi080bn.css
# 206/51101
Enter fullscreen mode Exit fullscreen mode

I asked for 499 bytes and 51,101 bytes came down. Same number on all three runs.
The payload grew 102-fold; I paid 101.4 bytes of envelope per piece. When I ran
the same experiment with 200-byte pieces, the number went somewhere even funnier:
I asked for 99,800 bytes and got 151,707 — while the entire file is 114,436
bytes. Asking for the pieces cost 37 KB more than downloading the whole thing.

Then I went up to 500 and it was over:

# 500 x 1 byte   -> 200/114436
# 500 x 200 bytes -> 200/114436
# 499 x 200 bytes -> 206/151707
Enter fullscreen mode Exit fullscreen mode

The edge's rule is clear: yes up to 499 ranges, and at 500 it behaves as though
it never saw the Range header and sends the whole body. The size of the pieces
doesn't change the decision at all — this is a count limit, not a budget.
Whether you ask for one byte or 200 per piece, the line is at 499.

What would my own server say?

Everything past this point would have been guesswork, so instead of guessing I
asked the server. The origin's 443 is open to Cloudflare only, but from inside
the VPS I can reach my own nginx with --resolve. Same requests, with the edge
out of the loop:

request Node (:3040) origin nginx 1.30.0 Cloudflare edge
single range, 100 bytes 206 / 100 206 / 100 206 / 100
3 ranges 200 / 114,436 200 / 114,436 206 / 346 (multipart)
499 × 1 byte 200 / 114,436 200 / 114,436 206 / 51,101
article page + range chunked, 200 200 / 268,824 200 / 269,938

The first thing that jumps out is that the first two columns are identical:
whatever Node says, nginx carries, on all four rows. So this isn't "three layers,
three answers" — it's "my own stack speaks with one voice, the edge with
another". Every multipart response in these tests came from Cloudflare; my own
stack can't do a multi-range request at all, because it delegates ranges to Node,
and the static file server Astro uses (send 1.2.1) only knows single ranges:
its ranges.length === 1 condition treats a multi-range request as a "regular
response" and sends the whole body.

Second: the same page is 268,824 bytes at the origin and 269,938 at the edge. The
1,114-byte difference is most likely the cached copy being an older render; I
didn't chase it, that wasn't today's question.

Diagram

What nginx alone would do: one budget, three predictions

What if nginx served the file itself? Trying that at the origin would have meant
changing the configuration; instead I put a file of the same size into the lab
container (114,436 bytes) and raised the range count.

n=453 -> 206/43294
n=454 -> 206/43390
n=455 -> 200/114436
Enter fullscreen mode Exit fullscreen mode

At 454 it produces multipart; at 455 it ignores the Range header. An odd
number. It has nothing to do with header length: by requesting pieces at offsets
above 100,000 I pushed the header to 6,369 bytes and the break stayed at 455. So
the decision isn't made by the count of ranges but by something else.

The answer is in the range filter's source, in the last ten lines of
ngx_http_range_parse():

if (max_ranges == NGX_MAX_INT32_VALUE) {
    size += ctx->ranges.nelts * 256;
    content_length += 4096;

    if (size > content_length) {
        return NGX_DECLINED;
    }
}
Enter fullscreen mode Exit fullscreen mode

size is the total of the requested bytes, nelts the number of ranges. nginx
assumes 256 bytes of envelope per piece, grants the source 4 KB of slack, and
refuses a request that comes close to the whole body. So it's a budget:
requested bytes + 256 × number of ranges > file size + 4096 means there are
no ranges.

I tested the formula on two more file sizes, computing the break point first and
measuring afterwards:

file range size prediction measurement
114,436 bytes 5 bytes 454 accepted / 455 refused 454 accepted / 455 refused
10,000 bytes 5 bytes 54 accepted / 55 refused 54 accepted / 55 refused
1,048,576 bytes 10,000 bytes 102 accepted / 103 refused 102 accepted / 103 refused

A direct hit all three times. This budget hasn't always been in nginx: it arrived
with commit
cb9252ae
on 13 January 2026, titled "Range filter: reasonable limit on multiple ranges".
The release notes list it as a bugfix — 1.29.5, 4 February 2026: "a response with
multiple ranges might be larger than the source response". The 1.30.0 at my
origin and the 1.31.8 in my lab both come after that fix.

The trap: writing max_ranges cancels the budget

Look at that if condition again: the budget only runs while max_ranges is at
its default. The moment you set the directive to a number, the code never
enters that block and all you have left is a flat count limit. Here is what came out of the lab:

default           n=455 -> 200/114436   (budget refused it)
max_ranges 1000   n=455 -> 206/43486    (no budget, accepted)
Enter fullscreen mode Exit fullscreen mode

So writing a generous max_ranges is weaker than not writing it at all.
nginx's documentation still says "By default, the number of ranges is not
limited" — true as a count, but since 1.29.5 the default carries a size budget,
and writing the directive takes it away. The commit message says the limit "can
be altered with the max_ranges directive", which is technically right: you alter
it, and a flat count takes its place.

There's an important distinction here, so don't skip it: writing max_ranges
doesn't leave you defenseless. Right above the envelope-aware budget sits a
cruder check that has been in nginx since 1.1.2 — if the sum of the requested
bytes exceeds the file itself, the request is refused — and that check lives
outside the max_ranges block. With max_ranges 1000 in place, the line falls
here:

n=572  requested 114,400 bytes (file 114,436) -> 206/167597
n=573  requested 114,600 bytes               -> 200/114436
Enter fullscreen mode Exit fullscreen mode

The break is exactly at the file's size. So with the directive written, the
ceiling of amplification becomes "file size + piece count × envelope": that
572-range request pulled 167,597 bytes out of a 114,436-byte file, 1.46 times
over. The envelope budget had already cut the same request off at 400 ranges.

Your three real options behave like this (all measured):

  • default: a single range always passes; multiple ranges pass up to the budget.
  • max_ranges 1: single range 206, multiple ranges 200. Resumable downloads and video seeking keep working; multipart closes entirely.
  • max_ranges 0: the Accept-Ranges header is never sent and every response is a 200.

There's a third limit, and I hit it by accident. Generating ranges as
0-4,10-14,20-24… (f'{i*10}-{i*10+4}') and raising the count with
max_ranges 1000 in place, the response flipped to 400 at range 841:

n=840  Range line 8,190 bytes -> 206
n=841  Range line 8,200 bytes -> 400
Enter fullscreen mode Exit fullscreen mode

The cause isn't the ranges but the request itself: the Range line no longer
fits the default large_client_header_buffers (4 8k). That's where the
natural ceiling of range-based amplification sits — the request has to fit into
a single header line.

What's in the envelope, and what nginx assumes

Opening up a two-range response byte by byte shows what the tax actually is:

\r\n--f822d9a65821054d\r\n
Content-Type: text/css; charset=utf-8\r\n
Content-Range: bytes 0-9/114436\r\n\r\n
@keyframes
\r\n--f822d9a65821054d\r\n
...
Enter fullscreen mode Exit fullscreen mode

Ninety-six bytes of envelope for ten bytes of data. The averages came out at
101.4 bytes per piece on the edge and 90.6 bytes on the lab's nginx. All 10.8
bytes of that gap come from the Content-Type line — the edge appends
; charset=utf-8, the local nginx doesn't. The boundary string actually works
the other way: nginx's boundary is a zero-padded twenty-character number, the
edge's a sixteen-character hex string. nginx's budget, meanwhile, assumes
256 bytes per piece, two and a half times reality.

The envelope isn't quite constant either: it grows with the digit count of the
offsets in each Content-Range line. Pieces asked for at the end of a file cost
a few bytes more than pieces at the beginning.

That generosity isn't accidental. For the same file, the break point moves like
this depending on the assumption:

assumed envelope break point
91 bytes (actual) 1,234 ranges
101 bytes 1,118 ranges
256 bytes (nginx) 454 ranges

So nginx also refuses requests it could have served without trouble. The commit
message says as much: a client asking for small pieces of a large source is
penalized, because there the processing cost dominates the data. That reads right
to me — a browser has no reason to ask for 450 separate bytes.

The moment I slice, I lose compression

A side effect I wasn't looking for turned up here. When I ask for the same CSS
with brotli accepted, 20,634 bytes come down. The moment I add a Range header:

curl -s -D- -o /dev/null -H 'Accept-Encoding: br' -H 'Range: bytes=0-99' \
  https://mustafaerbay.com.tr/_astro/BaseLayout.CGi080bn.css
# HTTP/2 206
# content-length: 100
# content-range: bytes 0-99/114436
Enter fullscreen mode Exit fullscreen mode

The content-encoding header disappears and the total in Content-Range is
114,436 — the length of the uncompressed body. Ranges are served over the raw
representation, not the compressed one. A client that tries to fetch this file
piece by piece carries 5.5 times what brotli would have cost. An earlier
measurement turned on which representation compression picks; once ranges enter
the picture, that choice is cancelled outright.

A single range is privileged, and a stale copy gets the whole body

One last detail about the budget: if the range count is exactly one, the source
returns NGX_OK without consulting the budget at all. So a single-range request
— a resumable download, seeking in a video, pulling the last page of a PDF —
passes at any size and on any file. Asking for 100 bytes of the 1 MiB file gave
me a 206 too.

Alongside that path there is a validator. A client holding half a copy can say
"continue where I left off if my copy is still valid" with If-Range:

ET=$(curl -sI https://mustafaerbay.com.tr/_astro/BaseLayout.CGi080bn.css \
  | grep -i '^etag' | sed 's/etag: //I')
curl -s -o /dev/null -w '%{http_code}/%{size_download}\n' \
  -H 'Range: bytes=0-99' -H "If-Range: $ET" .../BaseLayout.CGi080bn.css
# 206/100          <- tag matches, the piece arrives
curl -s -o /dev/null -w '%{http_code}/%{size_download}\n' \
  -H 'Range: bytes=0-99' -H 'If-Range: "bogus-etag"' .../BaseLayout.CGi080bn.css
# 200/114436       <- tag didn't match, the whole body arrives
Enter fullscreen mode Exit fullscreen mode

Getting a 200 rather than a 412 on a non-matching tag is by design: the server
sends the new body directly so the client doesn't have to make a second request.
In practice that means a client trying to finish a half-finished download will
quietly pull the entire file if the asset changed during a deploy. Files under
/_astro/ carry a content hash in their name, so this doesn't happen to me; it
happens if you serve a file with a stable name.

Two requests the edge won't take

RFC 9110 grants the server a right: it may reject overlapping ranges, or a set of
many small ranges that aren't listed in ascending order, since both point at
either a broken client or a deliberate request. The standard advises this without
requiring it. Each layer got the question separately.

request lab nginx Cloudflare edge
bytes=0-99,200-299 (ascending) 206 206
bytes=200-299,0-99 (unsorted) 206 / 403 bytes 200 / whole body
bytes=0-199,100-299 (overlapping) 206 / 604 bytes 200 / whole body
bytes=999999-1000099 (past the end) 416 416

The edge follows the RFC's advice and nginx doesn't: unsorted or overlapping, it
slices and sends. On 416 they agree — the response carries
Content-Range: bytes */114436, telling the client the file's real size.

That table also explains the 499-range measurement above: the bytes requested
were in ascending order, which is why the edge took them. Shuffled, the same 499
ranges would have brought back the whole body without a single slice.

The decision for my own setup

Here's the table I'm left with: my origin's nginx is on a post-fix version and
carries the budget, but the budget never fires — because Node already refuses the
multi-range request, and the face turned outward is Cloudflare, whose limit is a
count of 499. I don't own the defense.

So I changed nothing on the origin side today, and I'm not going to set
max_ranges to a number — the moment I write it I lose the only smart limit I
have. The real limit is at the edge and I have no knob there; my one lever, if it
ever matters, is lowering the range count at the edge.

The scale deserves to stay in the right place too. 102-fold sounds bad, but the
absolute figure is 51 KB; the amplification is bounded by the file size and the
request has to fit in a single header. This is a tax, not a weapon. Still, it's
good to know it isn't free: while marking the files under /_astro/ as
immutable for a year, I had never thought about how they get sliced.

You can measure your own stack from the same spot with four commands:

# 1) does the page do ranges?
curl -sI https://<site>/ | grep -i accept-ranges
# 2) does a static asset serve a single range?
curl -s -o /dev/null -w '%{http_code}/%{size_download}\n' -H 'Range: bytes=0-99' https://<site>/<asset>
# 3) is multipart produced?
curl -sI -H 'Range: bytes=0-9,20-29' https://<site>/<asset> | grep -i content-type
# 4) up to how many ranges?
R=$(python3 -c "print(','.join(f'{i*7}-{i*7}' for i in range(499)))")
curl -s -o /dev/null -w '%{http_code}/%{size_download}\n' -H "Range: bytes=$R" https://<site>/<asset>
Enter fullscreen mode Exit fullscreen mode

If the second request returns 206, slicing is on; if the third says
multipart/byteranges, you're paying the envelope tax; if the fourth returns
206, the layer in front of you doesn't honour nginx's budget.

What this measurement taught me isn't really about ranges. Every layer of my
stack can answer the same request differently, and I only get to see which answer
counts by interrogating the layers one at a time. That ten-line budget nginx
added in January sits on my server — unused. It's nice when defaults improve;
whether that default ever sees your request is a separate question. The
gzip_static measurement
taught me the same lesson more cheaply: a static file module never touches a
response coming through proxy_pass — writing that directive would not have
affected a single path carrying traffic. This time the thing that doesn't touch
my traffic isn't a speed knob, it's a defense.

Official Sources

Top comments (0)