Skip to main content
Nima Amini

Nima Amini

Technical Lead/Systems Architect, based in Milan, Italy

SQS payload size, 64 KiB billing, and the 1 MiB cap

Amazon SQS looks simple until SendMessage returns Message must be shorter than 262144 bytes. The worker is fine. The producer might think it dispatched. But the queue never saw the job.

I hit that with Symfony Messenger. The default transport serializer is native PHP serialize, then addslashes(), then base64 encoding. The object was fine. The body SQS received was non-existent.

There are a few ways around a 256 KiB bounce:

  1. JSON encode instead of PHP serialize.
  2. Raise the queue cap to 1 MiB.
  3. Park the payload in S3 (Extended Client).
  4. If the row is already in the database, put a pointer on the queue and hydrate on consume.

This post is about the size model, some practical know-how and the bill for each. In the end I chose to JSON encode.

What SQS counts

SQS counts the HTTP body you send, including attributes. It does not count your in-memory DTO.

When using Symfony Messenger, the default serializer (messenger.transport.native_php_serializer) inflates that body through three steps:

  1. serialize($envelope): class names, visibility markers, null bytes for private fields
  2. addslashes()
  3. base64_encode(): required because serialize() emits null bytes and SQS wants plain text

Base64 adds about 33% on its own on top of an already verbose binary dump.

How SQS bills

SQS bills per request, not per logical message. A request is a 64 KiB chunk of payload. Ireland standard queues: $0.40 per million requests after the first million free. FIFO is $0.50. Same-region data transfer is free.

Body sizeUnits per Send (and again per Receive)
1–64 KB1
65–128 KB2
129–192 KB3
193–256 KB4
+1 per 64 KB
1 MiB16

Delete is usually one unit. A successful trip is send + receive + delete. Empty receives on short polling are extra requests with no payload.

SendMessage that fails the size cap never appears in CloudWatch SentMessageSize. That metric only records what landed. A new fattier data source looks like a sudden outage. The error should be logged and looked up in the producer logs. Logging the string length of the body before send can be a useful context in your logs.

What the alternatives cost

Figures below are Ireland standard SQS at $0.40 per million requests, per million successful trips, ignoring empty receives and the free tier. S3 is Standard in the same region: PUT about $0.005 per 1,000, GET about $0.0004 per 1,000, DELETE free. Storage for objects that live minutes is noise on S3 Standard, which has no minimum duration. Standard-IA and One Zone-IA still bill 30 days if you delete after consume. Glacier Instant Retrieval and Flexible Retrieval bill 90 days. Deep Archive bills 180 days. So just in case, keep Extended Client payloads on Standard if containing the costs is important and you have no particular reason to keep these objects hanging around (audit, replaying the events, etc...).

ApproachSQS bodySQS requests / tripSQS / million tripsExtraTotal / million
JSON, under 64 KiBtiny JSON3$1.20none~$1.20
PHP envelope that almost fills 256 KiB~251 KiB9$3.60none~$3.60
Raise cap, body grows toward 1 MiB1 MiB33$13.20none~$13.20
Extended Client (S3)stub, always 1 unit3$1.201M PUT + 1M GET ≈ $5.40~$6.60
Pointer to a row already in MySQLstub, always 1 unit3$1.20extra SELECT on every consume~$1.20 SQS, more DB load

Raising MaximumMessageSize does not change the bill by itself. A 251 KiB body is still 9 units after the cap moves. The extra spend appears when bodies use the new headroom: PHP serialize will, if you let it. A million trips that stay under 64 KiB cost $1.20. The same million at ~251 KiB cost $3.60 (3×). A million that fill 1 MiB cost $13.20 (11×). One CLI command stops the bounce. It also invites 16 send units and 16 receive units on purpose.

Extended Client is cheaper than a 1 MiB SQS body (~$6.60 vs ~$13.20 per million) and more expensive than a small SQS body, because S3 PUT dominates. That is a different problem: a blob that belongs in object storage.

Passing a database pointer keeps your SQS costs in the same minimal tier as a small JSON payload. SQS stays cheap, but your database takes on more query traffic. Switch to JSON first. Move to pointers only if you need to shrink the body further and can accept the additional database reads.

JSON encode, dual decode

Switching to JSON eliminates class metadata, null bytes, and base64 encoding. The exact same payload fits in a fraction of the space.

However, you cannot switch the encoder to JSON in a single deployment if the queue already holds in-flight PHP-serialized messages. Workers must continue decoding old bodies while new messages arrive as JSON.

The solution is a transport-level dual-format decoder:

  • Detect format: Trim the payload string. JSON objects and arrays start with { or [. PHP-serialized strings start with type markers followed by a colon (O: for objects, a: for arrays, C: for custom classes).
  • Decode: If the body starts with { or [, decode it as JSON. Otherwise, fall back to PhpSerializer.
  • Encode: Write JSON for all outbound messages immediately.

Once legacy PHP envelopes drain completely, drop the PhpSerializer fallback path entirely. Dual-format decoding is a temporary migration tactic, not a permanent architecture.

Raise the cap to 1 MiB

In August 2025, AWS raised the SQS service maximum from 256 KiB to 1 MiB for both standard and FIFO queues. Lambda event-source mappings were updated alongside it. There is no account feature flag. The API accepts 1 MiB if the queue allows it.

New queues default to 1 MiB, but existing ones do not change automatically. If you previously configured MaximumMessageSize, it stays at that value. Any Infrastructure as Code (IaC) template hardcoded to 262144 will lock your queue at 256 KiB forever.

Set MaximumMessageSize:

  • Check and update the attribute via CLI:
aws sqs get-queue-attributes \
  --queue-url "$QUEUE_URL" \
  --attribute-names MaximumMessageSize

aws sqs set-queue-attributes \
  --queue-url "$QUEUE_URL" \
  --attributes '{"MaximumMessageSize":"1048576"}'
  • Console: SQS → Queue → Edit → set Maximum message size slider to 1024 KiB
  • CloudFormation: MaximumMessageSize: 1048576
  • CDK: maxMessageSize: 1048576 on Queue (upgrade aws-cdk if synthesis rejects values over 262144).

Always update your IaC code to match. If your template still says 262144, the next deployment will overwrite your change and drop the queue back to 256 KiB.

Extended Client (payload in S3)

The Extended Client is a client library pattern, not a queue configuration. AWS maintains official SDKs for Java, Python, and .NET, but there is no official PHP library. To use this in PHP, you rely on a community package or write custom wrapper logic around aws/aws-sdk-php.

SQS itself has no idea S3 is involved. Both the producer and consumer must use compatible library logic for it to work.

How it works:

  1. Producer: If the payload exceeds a set threshold (256 KiB by default, or 64 KiB to stay within a single billing chunk), the library uploads the body to S3 via PutObject.
  2. SQS dispatch: SQS receives a small JSON pointer containing the bucket, key, and size. This stub is only a few hundred bytes, so it always costs exactly one SQS request unit.
  3. Consumer: The consumer reads the pointer from SQS, fetches the payload from S3 via GetObject, passes the raw bytes to your application logic, and issues a DeleteObject to clean up S3.

The catch:

Every worker, Lambda function, and operational replay script in your pipeline must speak the protocol. A consumer without the library receives the raw JSON pointer and treats that stub as the actual message payload.

You also trade SQS size walls for new operational surface area: bucket IAM policies, added S3 GET latency on every read, orphaned objects if a worker crashes before issuing DeleteObject, and bucket lifecycle rules to keep S3 from filling up. Use it for files or batch records that genuinely belong in object storage.

Pointer on the queue, hydrate on consume

If the payload is already persisted in MySQL (such as an outbox table, buffer record, or primary document), you can shrink the body by queueing record IDs and loading the row during consumption.

This is a secondary optimization, not the primary fix. While SQS payload size stays at a single request unit, every message processed adds a SELECT query. You keep SQS cheap by shifting work to your database.

Key considerations:

  • Commit before dispatch: Always dispatch the message after the database transaction commits. Sending a pointer to an uncommitted record causes a race condition where the worker attempts to read the row before the insert completes.
  • Handle missing rows: Your consumer must gracefully handle missing records caused by early deletions, cleanup jobs, or TTL expiration before the retry window closes.
  • Support dual decoding during rollout: Your consumer must handle both full payloads and pointer stubs simultaneously while in-flight messages clear from the queue.
  • Don't reinvent S3: If the payload is not already stored in your database, do not save it to MySQL just to queue a pointer. Building a custom claim-check pattern in a database is far worse for performance than using S3.