DEV Community

Cover image for The 49-Byte Field That Doubles Our SQS Bill
vadim albarov
vadim albarov

Posted on

The 49-Byte Field That Doubles Our SQS Bill

TL;DR: Amazon SQS does not bill per message. It bills per request, and every 64 KiB chunk of payload counts as one request. A message of 65,536 bytes is one request. A message of 65,537 bytes is two. If your producer packs messages tightly under that line (a lot of chunkers do exactly this, on purpose), then adding a harmless traceId to the envelope doubles the billed requests for every send and every receive, and the total SQS bill goes up 67% to 95% depending on how you delete. Every price here comes from the AWS pricing page; the scenario is invented to show the mechanism. Here is how the cliff works, how to see whether you are standing on it, and how to make it a slope instead.

The rule nobody re-reads

The SQS pricing page has one sentence that matters here:

Each 64 KB chunk of a payload is billed as 1 request.

The example right below it says a 1 MiB payload is billed as 16 requests. 16 × 65,536 = 1,048,576, so "64 KB" means 64 KiB and the chunk is 65,536 bytes. AWS does not publish the exact boundary or which bytes it counts, so treat 65,536 as the implied line, not a documented one.

Two more facts complete the picture:

  • Message attributes count. The developer guide says all components of a message attribute (name, type, value) are included in the message size. Moving a field from the body to an attribute does not hide it.
  • The rule applies to every API action's payload. SendMessage is chunked on the way in. ReceiveMessage is chunked on the way out, and its response also carries receipt handles and metadata, so a message near the line may already cost two on receive. DeleteMessage carries only a receipt handle, so it stays at one request.
  • One exception. The X-Ray system attribute AWSTraceHeader does not count toward message size. If your tracing is X-Ray, use it. The scenario below assumes it is not.

The prices themselves are small, which is exactly why nobody looks:

Queue type Per million requests (first 100 billion, us-east-1)
Standard $0.40
FIFO $0.50

A scenario that is boring enough to be real

Imagine a service that exports large reports through SQS. Years ago, someone read the pricing page and wrote a chunker: split the report into pieces, each sized to fit in 64 KiB, send each piece as one message. The constant is called MAX_CHUNK_BYTES and it is set to 65_536 minus the size of a small JSON envelope. There is no comment explaining the number.

The chunker works. The event records are small, so every full chunk lands within a few dozen bytes of the line, between about 65,490 and 65,535 bytes. One request each. Nobody thinks about it again.

Then a debugging ticket comes in. Cross-service tracing is painful, so the team adds a trace ID to the shared message envelope library. The change is one line in the serializer:

,"traceId":"4f1c2b7e-9a3d-4e58-b2f0-6c8d1a9e3b47"
Enter fullscreen mode Exit fullscreen mode

That is 49 bytes. The PR is reviewed in four minutes. Tests pass. It ships.

The chunker never sees this field. It sized the payload before the envelope library serialized it. So every full chunk is now 65,539 to 65,584 bytes. All of them cross 65,536.

What changes:

  • NumberOfMessagesSent in CloudWatch: unchanged.
  • Throughput, latency, error rate: unchanged.
  • Billed requests per message: send goes from 1 to 2, receive goes from 1 to 2, delete stays at 1.

Nothing in the usual dashboards moves. The bill does.

The math

Take a Standard queue, no batching, 1 billion chunk messages per month (about 390 per second, sustained). Consumers are EC2 or Lambda in the same region, so there is no data transfer charge. Ignore the free tier and empty receives.

Requests per message Requests per month Cost per month
Before (send 1 + receive 1 + delete 1) 3 3 billion $1,200
After (send 2 + receive 2 + delete 1) 5 5 billion $2,000

That is +$800 per month, $9,600 per year, for 49 bytes nobody needed in production. On a high-throughput FIFO queue it is $1,500 to $2,500 (a default FIFO queue caps at 300 unbatched requests per second per partition, so this workload would not fit without batching).

Notice the total is +67%, not +100%. The send and the receive did double. The delete did not, because it carries only a receipt handle, and it dilutes the ratio. You cannot skip it: an undeleted message comes back after the visibility timeout and gets received, and billed, again.

Most consumers delete in batches, though. With DeleteMessageBatch at 10 per call the delete almost disappears from the math, and the total goes from 2.1 to 4.1 requests per message, +95%. So the honest range is "the bill goes up 67% to 95%, depending on how you delete", and the honest headline is "the cost of every message you send and receive doubles". If you say "doubled" in a meeting, be ready to say which one you mean.

Also notice the shape of the function. The cost per message is:

requests = ceil(size_in_bytes / 65536)
Enter fullscreen mode Exit fullscreen mode

It is a staircase. Between steps it is free to grow. At each step it jumps. The 49-byte field was expensive only because the messages were already standing on the edge of a step. The same 49 bytes added to 20 KiB messages cost nothing at all.

How to know if you are on the edge

You cannot see this from message counts. You need sizes.

CloudWatch, per queue. The SentMessageSize metric has Average, Maximum, and SampleCount. Plot Maximum and Average against a horizontal line at 65,536. If the Average sits within a few hundred bytes under the line, you have a packer, and you are on the edge.

Cost Explorer. Filter to SQS, group by usage type. The line is Requests-Tier1 or Requests-RBP (FIFO has its own variants, and regions outside us-east-1 add a prefix). Divide monthly billed requests by NumberOfMessagesSent summed over your queues, after subtracting NumberOfEmptyReceives. For an unbatched workload with small messages the ratio is about 3. If it is about 5, your messages are between 64 KiB and 128 KiB and nobody told you.

One-off check. Pull 1,000 messages with ReceiveMessage and a short visibility timeout, dedupe on MessageId, measure the UTF-8 byte length of body plus attributes, and histogram it. A spike just under 65,536 is the signature.

Making the cliff a slope

Batch. This is the one people underestimate. AWS's wording applies the chunk rule to the payload of the API action, and a SendMessageBatch call holds up to 10 messages and up to 1 MiB in total. Read literally, the batch is summed and then rounded. Ten messages of 65,500 bytes are 655,000 bytes, which is 10 chunks. Ten messages of 65,549 bytes are 655,490 bytes, which is 11 chunks. AWS does not state the sum-then-round behaviour outright, so check it against your own bill before you depend on it. The same change that doubled the unbatched send cost adds 10% to the batched one. Batching does not just reduce request count. It averages out the rounding.

Same scenario, batched 10 at a time for send, receive, and delete:

Requests per month Cost per month
Before 2.1 billion $840
After 2.3 billion $920

+$80 instead of +$800.

Compress. SQS does not compress for you. It stores the bytes you send and bills on them. But JSON report chunks typically gzip 3x to 10x in application code. One catch: the message body must be valid text, so raw gzip output has to be base64-encoded, which adds about a third to the compressed size. A 65 KiB chunk still ends up around 15 to 25 KiB. A Binary message attribute with a one-character body avoids base64 in your code, but AWS does not document whether it bills the raw or the encoded bytes, so do not count on it saving money. Either way, mark compressed messages with an attribute like contentEncoding so consumers can handle both formats during rollout.

What does it cost in CPU? I measured a 65 KB JSON chunk with CPython's zlib on a desktop CPU:

gzip level Size after gzip + base64 Compress Decompress
1 18 KB (3.6x smaller) 0.6 ms 0.1 ms
6 14 KB (4.6x smaller) 1.4 ms 0.1 ms
9 13.5 KB (4.8x smaller) 6.1 ms 0.1 ms

Level 9 is not worth it. At level 1 and 1 billion messages a month, the producer needs about a quarter of a core on average and under one core at a daytime peak, and the consumer a few percent of a core. Call it 1 to 2 vCPUs on cloud hardware, about $60 a month, against an $800 a month increase. Latency per message goes up by about a millisecond, which is noise next to a 10 to 30 ms HTTPS round trip.

And once chunks are 14 KB, batching works again: ten compressed chunks are 140 KB, which is 3 billed chunks instead of 10. Compressed and batched, the same workload is about 0.7 billion requests a month, or $280. That is below where you started, not just back to it. You are now nowhere near a step, and you can add all the debug fields you like for years.

Size the envelope, not the body. If you keep a chunker, make it measure the final serialized message, after every library has had its turn. Better: make it a test.

def test_chunk_fits_in_one_billing_unit():
    msg = build_largest_possible_chunk()
    wire = serialize(msg).encode("utf-8")  # body + attributes, as sent
    assert len(wire) <= 65_536 - 512, (
        "SQS bills per 64 KiB chunk; see docs/sqs-billing.md"
    )
Enter fullscreen mode Exit fullscreen mode

The margin and the assertion message are the point. The original author of MAX_CHUNK_BYTES knew the rule. The test is how the next person finds out.

Claim check. If chunks are large because the payload is large, put the payload in S3 and send a pointer. The SQS Extended Client Library (Java and Python) does this for you. It was the standard answer before the limit went to 1 MiB in August 2025, and it is still the answer above 1 MiB.

What I would actually take from this

The uncomfortable part of the scenario is not the rule. It is that the system had the knowledge once, encoded it as a number, and lost the reason. A field that costs nothing to add, nothing to run, and nothing to test can still cost $9,600 a year, and the only place it shows up is a line in Cost Explorer that someone checks quarterly.

If you run SQS at volume, spend ten minutes this week: open SentMessageSize for your busiest queues and look at where the Average sits relative to 65,536. If it is close, you have found either a careful engineer from years ago or a bill waiting to happen. Usually both.

Top comments (0)