Back to Blog

Overcoming AWS SQS Limits: Building a Custom SQS+S3 Transport for Symfony Messenger

M
Mautomic Team
10 min read

Some of the hardest engineering problems aren't the ones you plan for. They're the ones that ambush you mid-implementation - when everything is working beautifully until you hit an invisible wall.

For us, that wall was a 256 KB message size limit in AWS SQS. A limit that turned a straightforward Symfony Messenger integration into a custom transport engineering project. A limit our lead developer called "ridiculously small" when he first encountered it.

Here's the story of how we discovered the problem, why we didn't switch away from SQS, and how we built a custom SQS+S3 transport that handles it transparently.

The Setup: Why We Chose SQS in the First Place

Our marketing automation platform runs on Kubernetes and processes email campaigns for hundreds of independent tenant instances. Every campaign send, every webhook delivery, every async task flows through message queues.

We chose AWS SQS as our queue system for several compelling reasons:

  • Reliability. SQS is a managed service with built-in redundancy. Messages are stored across multiple availability zones. We've never lost a message.
  • Built-in retry and dead letter queues. When a consumer fails to process a message, SQS automatically makes it available again after a visibility timeout. After repeated failures, messages move to a dead letter queue for investigation.
  • Pay-per-message pricing. For bursty workloads like email campaigns - where queue volume spikes from zero to thousands of messages in seconds - pay-per-message is far more cost-effective than paying for always-on queue infrastructure.
  • Deep AWS integration. IAM permissions, CloudWatch monitoring, alarm triggers - SQS fits naturally into our AWS-native infrastructure.
  • Symfony Messenger has an SQS Bridge. The Symfony ecosystem provides a ready-made transport adapter for SQS, reducing the integration effort.

SQS was the obvious choice. Until it wasn't.

The Problem: 256 KB Is Smaller Than You Think

AWS SQS has a hard limit of 256 KB per message. That sounds generous - 256 KB is a lot of text. But when you're sending email batch payloads with 1,000 recipients, each carrying personalization token values, the math gets tight fast.

Here's what a typical email batch payload contains:

  • Email template markup. The HTML body of the email, potentially including dynamic content blocks, conditional sections, and styling.
  • Recipient data for up to 1,000 contacts. For each contact: email address, name, and all personalization token values (branch name, office address, contact phone number, custom fields).
  • Symfony Messenger envelope metadata. This is the part that caught us off guard.

The business data alone - template plus 1,000 recipients - can easily reach 200+ KB for content-rich emails with many personalization fields. But the payload that actually hits SQS isn't just the business data. Symfony Messenger wraps every message in an envelope that includes:

  • Retry stamps. Metadata tracking how many times the message has been attempted and when to retry next.
  • Dispatch markers. Information about which bus dispatched the message and where it should be routed.
  • Transport configuration. Serialization format identifiers, class metadata, and routing information.
  • Custom stamps. Any application-specific metadata attached to the message.

This Symfony overhead adds unpredictable bytes to every message. A payload that's 230 KB of business data becomes 260 KB after Symfony wraps it - and suddenly you're over the SQS limit.

We set our threshold at 200 KB - a conservative buffer that accounts for the variable size of Symfony's metadata. Any payload that exceeds 200 KB after serialization gets offloaded to S3.

Why Not Just Switch Queues?

When we first hit this limit, the tempting answer was: "Just use a different queue system. RabbitMQ doesn't have this limit. Kafka doesn't have this limit."

But switching queue systems would have meant:

  • Rewriting the entire queue integration. Every producer, every consumer, every dead letter queue configuration, every retry policy - all of it would need to change.
  • Managing our own queue infrastructure. SQS is fully managed. RabbitMQ or Kafka on Kubernetes means we're responsible for availability, scaling, persistence, and monitoring of the queue itself.
  • Losing pay-per-message economics. Self-managed queue systems have fixed infrastructure costs regardless of volume. For our bursty workload profile, this is significantly more expensive.
  • Losing native AWS integration. IAM permissions, CloudWatch metrics, dead letter queue configuration - all the operational benefits of a managed service disappear.

The SQS message size limit is a real constraint, but it's a solvable one. And AWS themselves suggest the solution: offload large payloads to S3.

The Solution: Custom SQS+S3 Transport

We built a custom transport layer on top of the existing Symfony Messenger SQS Bridge. The transport is transparent to the rest of the application - consumers don't know or care whether a message came directly from SQS or was offloaded to S3.

How It Works: Sending Side

When a message is ready to be sent to the queue:

  1. Serialize the full message including all Symfony Messenger stamps and envelope metadata.
  2. Check the serialized size against our 200 KB threshold.
  3. If under the threshold: Send directly to SQS as normal. No S3 involvement.
  4. If over the threshold:

- Write the full serialized payload to an S3 bucket with a unique key. - Replace the SQS message body with a lightweight reference object containing the S3 bucket name and object key. - Send this reference to SQS. The reference itself is tiny - well under the 256 KB limit.

How It Works: Consuming Side

When a worker pulls a message from SQS:

  1. Check if the message body is an S3 reference. The transport uses a simple marker to distinguish direct payloads from S3 references.
  2. If it's a direct payload: Deserialize and process normally. No S3 involved.
  3. If it's an S3 reference:

- Download the full payload from the S3 bucket using the reference key. - Deserialize the downloaded content into the original Symfony Messenger envelope with all stamps intact. - Process the message exactly as if it had come directly from SQS.

  1. After successful processing: Delete the S3 object. No orphaned files accumulating in the bucket.

From the consumer's perspective, every message looks the same. The S3 offloading is completely invisible.

Engineering Challenges We Didn't Expect

Building this transport sounds straightforward on paper. In practice, several challenges made it one of the more demanding pieces of engineering in the project.

Preserving Symfony Messenger's Internal Format

Symfony Messenger doesn't just send your business data. It wraps it in a complex envelope structure with stamps, transport metadata, and serialization markers. When we offload to S3, we need to preserve this entire structure - not just the business payload.

This means serializing the complete Messenger envelope (stamps and all) to S3, and deserializing it back perfectly on the consumer side. Any mismatch in the serialization format and the consumer fails to reconstruct the message.

We had to ensure that the serialization used for S3 storage is identical to what the SQS Bridge normally handles. The custom transport essentially intercepts the message after Symfony has prepared it for SQS, and redirects the serialized form to S3 when it's too large.

Error Handling: When S3 Is Unavailable

What happens if S3 is temporarily unavailable when a consumer tries to download a payload?

  • The consumer can't process the message.
  • It needs to fail in a way that SQS understands - the message should become visible again after the visibility timeout, so another worker can retry.
  • The S3 object must not be deleted (since processing didn't succeed).
  • The retry stamps in the Symfony envelope must be updated correctly on the next attempt.

We handle this through standard Symfony Messenger retry logic - if the S3 download fails, the consumer throws an exception, the message isn't acknowledged, and SQS makes it available again. The retry and dead letter queue mechanisms work exactly as they would for any other processing failure.

Retry Cycles and S3 References

When a message fails processing and returns to the queue for retry, the S3 reference must survive the retry cycle intact. The same S3 object is referenced across multiple processing attempts. Only after successful processing (or final dead-lettering) should the S3 object be cleaned up.

This means the S3 reference isn't just a simple URL - it carries enough information for the system to track the lifecycle of the payload across retries.

Latency Overhead

Every S3-offloaded message adds latency: a write on the sending side and a read on the consuming side. For our email batch workloads, this latency is negligible - we're talking about milliseconds of S3 I/O versus seconds of actual email processing. But for latency-sensitive workloads, this overhead would need careful evaluation.

In practice, the S3 operations add roughly 50-100ms per message. Given that our consumers spend 30-60 seconds processing a batch of 1,000 emails, this overhead is invisible.

What Percentage of Messages Hit S3?

Not every message exceeds the threshold. In our system:

  • Small messages (acknowledgments, webhooks, lightweight async tasks) go directly to SQS. They're well under 200 KB.
  • Medium messages (email batches with fewer recipients or simpler personalization) often fit within the SQS limit. These also go directly.
  • Large messages (full email batches with 1,000 recipients and rich personalization data) trigger the S3 offloading.

The ratio depends on campaign content. A simple text email with basic personalization might keep batches under 200 KB. A rich HTML email with dozens of personalization tokens per recipient almost certainly exceeds it.

The key design principle is that the transport handles this transparently. Neither the producer nor the consumer needs to know which path a message took.

Testing Under Fire

The custom transport was validated during our 1-million email stress test. With approximately 52 parallel workers consuming messages - a mix of SQS-direct and SQS+S3 messages - we observed:

  • Zero message loss. Every payload, whether direct or S3-offloaded, was processed successfully.
  • No serialization errors. The Symfony Messenger envelope, including all stamps, was preserved perfectly across the S3 round-trip.
  • No orphaned S3 objects. Post-test, the S3 bucket was clean - all objects were deleted after successful processing.
  • Negligible latency impact. The S3 overhead was invisible in the overall processing time.

When to Use This Pattern

The SQS+S3 offloading pattern applies beyond our specific use case. Consider it when:

  • Your SQS payloads are variable in size and some exceed 256 KB. If all messages are always large, you might reconsider your message design. But if most are small and some are large, S3 offloading is the pragmatic solution.
  • You need SQS's reliability features (managed service, dead letter queues, visibility timeout) but can't compromise on payload size.
  • You're using Symfony Messenger and want to stay within its transport abstraction rather than building a completely custom queue client.
  • Batch processing with variable payload sizes - email batches, report generation, data export - where payload size depends on business data that you can't control.

A Pattern AWS Recommends (But Doesn't Implement for You)

AWS's own documentation suggests using S3 for large SQS payloads. They even provide a Java library (Amazon SQS Extended Client Library) for this pattern. But for PHP and Symfony Messenger, no such library existed.

We built our custom transport because:

  • The Java library doesn't help PHP applications.
  • Generic S3 offloading doesn't account for Symfony Messenger's envelope structure.
  • We needed the transport to be invisible to our consumers - no code changes in any message handler.

The result is a transport that plugs into Symfony Messenger's transport layer, respects its serialization format, preserves all stamps and metadata, and handles the S3 lifecycle automatically.

Key Takeaways

  1. Know your queue limits before you build. The 256 KB SQS limit isn't mentioned in most SQS tutorials. We discovered it mid-implementation - don't let that happen to you.
  1. Don't switch infrastructure to solve a payload problem. SQS's reliability, pricing, and managed nature are hard to replace. Offloading large payloads to S3 is cheaper and less risky than migrating to a different queue system.
  1. Symfony Messenger adds overhead. When calculating payload sizes, account for stamps, dispatch markers, and serialization metadata. Your business data might fit in 256 KB, but the full Messenger envelope might not.
  1. Build it transparent. The best infrastructure components are invisible to the code that uses them. Our consumers don't know about S3 - they just process messages.
  1. Test with realistic payloads. Small test messages won't trigger the S3 path. Use production-sized payloads in your tests to exercise both code paths.

Ready to Build Scalable Queue Infrastructure?

If you're hitting SQS limits, struggling with message size constraints, or building high-throughput systems on Symfony and AWS, we've been through these challenges and can help you navigate them.

[Book a free consultation](https://www.droptica.com/contact/) to discuss your Symfony and AWS architecture needs.

M

Written by

Mautomic Team

The Mautomic team brings together experienced marketing automation specialists, developers, and consultants dedicated to helping businesses succeed with Mautic.

Need Help with Mautic?

Our team of experts is ready to help you implement and optimize your marketing automation.

Get in Touch