The Claim Check Pattern in ASP.NET Core: When to Use It and How

Every message broker in production has a payload size limit โ and eventually, your application will hit it. Azure Service Bus (Standard tier) caps individual messages at 256 KB. Amazon SQS enforces the same 256 KB ceiling. RabbitMQ's default is higher, but cloud-hosted deployments routinely lower it. When a business event carries a full order payload, an invoice document, a batch of enrichment data, or a serialised audit record, you're not staying under that ceiling forever. The claim check pattern in ASP.NET Core gives you the architectural exit before you hit the wall โ and well-designed systems apply it before the limit is ever reached, not after.
The concepts covered here connect naturally to production messaging patterns with real failure modes and edge cases. If you want the full implementation โ the payload store abstraction, the producer pipeline, the consumer retry logic, and the test harness โ Patreon has the complete, annotated source code that maps directly to how enterprise teams wire this up in practice.
The pattern itself is simple in concept: instead of embedding a large payload inside a message, the producer stores the payload in an external data store and publishes only a lightweight token referencing it. The consumer receives the token, retrieves the payload from the store, processes it, and manages cleanup on its own schedule. The broker carries the reference. The data lives where data belongs.
What Problem Does the Claim Check Pattern Solve?
Message brokers are designed for routing, ordering guarantees, and reliable delivery โ not bulk data transport. When applications treat brokers as data pipes and start embedding full documents, large JSON blobs, or binary payloads inside messages, several problems emerge simultaneously.
Broker size limit violations are the most visible failure. A payload that is 200 KB today becomes 400 KB six months later when the business adds new required fields to an order event. Applications that embed payload directly into messages build on an invisible ceiling that cracks under normal business growth.
Broker memory pressure is less visible but just as damaging. Even when payloads are technically within limits, large messages consume broker memory that should drive throughput. Queues slow down, consumer lag builds, and alerting thresholds start firing โ not because the system is under extraordinary load, but because each individual message carries too much weight.
Payload duplication in fan-out topologies multiplies the problem. In a publish/subscribe model, if twelve subscribers receive the same order event, the full payload is duplicated twelve times across the broker. With a claim check token, the broker replicates only the reference โ each subscriber fetches the payload independently, only when ready to process.
Payload lifecycle rigidity is a softer but important consideration. When the payload is embedded in the message, its lifecycle is tied to the message's lifecycle. There is no easy way to expire it early, apply GDPR erasure, archive it to cold storage, or version it independently of the event schema. Externalising the payload gives each concern its own independently managed lifetime.
Core Concepts of the Pattern
The pattern involves three roles: a producer, a payload store, and a consumer.
The producer is the service that generates the business event. When a payload is large enough to warrant the pattern, the producer stores it in the external payload store, captures the returned reference, and publishes a lightweight message containing only the reference and any metadata needed for routing decisions.
The payload store is an external data store optimised for the content type. Azure Blob Storage and Amazon S3 are the standard choices for unstructured content. A dedicated database table works for structured payloads. Whatever the implementation, the store is responsible for durability, access control, and lifecycle management โ three concerns the broker should never own.
The consumer receives the lightweight message, reads the claim check reference, fetches the payload from the store, and processes it through its business logic. Depending on the system design, the consumer may delete the payload after successful processing, or leave lifecycle management to a separate scheduled process.
The claim check token itself is typically a Guid-based identifier or a direct storage URL. At its simplest:
public record ClaimCheckMessage(
Guid EventId,
string PayloadReference,
string EventType,
DateTimeOffset OccurredAt
);
This is what travels through the broker. The payload is absent by design.
When Should You Use the Claim Check Pattern?
When payloads approach broker size limits
Design for growth, not just the current size. A payload at 80% of the broker's limit should trigger adoption of the pattern before the overage arrives โ not after it causes production failures. Refactoring a message schema under live traffic is significantly more expensive than designing the boundary correctly upfront.
When multiple consumers need the same data
Fan-out architectures amplify the cost of large payloads. With a claim check, the broker replicates a token. Each consumer independently decides when and whether to retrieve the full data, creating a lazy evaluation model that's aligned with how async systems actually work.
When payloads contain binary data or blobs
Documents, images, PDFs, and binary content have no place in a message body. Their home is object storage, built specifically to store, serve, and lifecycle-manage binary data efficiently. The claim check pattern enforces this architectural boundary with no exceptions.
When independent lifecycle management is required
GDPR right-to-erasure requirements, data retention policies, and audit log archiving all become significantly easier when the payload is separate from the event. You can apply a TTL at the storage layer, process deletion requests by removing the stored object, or move payloads to cold storage without touching the message history at all.
When consumer processing is asynchronous and variable-speed
If consumers are not processing messages the instant they arrive โ which is the fundamental value proposition of a message broker โ there is no reason for the broker to hold full payloads in memory during the wait. This is especially relevant for systems where certain consumers are slower by design, such as those triggering ML inference, sending external notifications, or writing to secondary databases.
When Should You Avoid the Claim Check Pattern?
Payloads are small and stable. When messages are consistently under 20-30 KB and unlikely to grow significantly, the additional latency from a storage round-trip adds operational complexity without a proportionate return.
Consumers require strict latency constraints. The pattern adds a network hop between message receipt and payload access. For real-time processing pipelines where end-to-end latency is under a second and every millisecond matters, that hop may tip the budget.
The payload store becomes a hot-read bottleneck. In a high-throughput fan-out scenario where hundreds of consumers retrieve the same payload object concurrently, your storage tier can localise into a hot read problem. This is solvable โ CDN delivery, storage-level read replicas, or a caching layer in front of the store โ but it introduces complexity that has to be planned for, not discovered under load.
You cannot tolerate additional failure domains. The Claim Check Pattern means a consumer can receive a valid, successfully delivered message and still fail to process it if the payload store is transiently unavailable. This is a new failure mode your observability, alerting, and retry infrastructure must account for.
What Does the Implementation Look Like in ASP.NET Core?
A clean implementation in ASP.NET Core follows three layers: a payload store abstraction, a producing service, and a consuming handler.
The IPayloadStore interface is the central abstraction:
public interface IPayloadStore
{
Task<string> StoreAsync<T>(T payload, CancellationToken ct = default);
Task<T?> RetrieveAsync<T>(string reference, CancellationToken ct = default);
Task DeleteAsync(string reference, CancellationToken ct = default);
}
The concrete implementation โ backed by Azure Blob Storage, Amazon S3, or any BlobServiceClient equivalent โ wires into ASP.NET Core's DI container at startup just like any other scoped or singleton service. Domain logic never references the storage SDK directly; it depends only on IPayloadStore. This keeps business handlers testable in isolation, and lets you swap implementations across environments without touching application code.
The producing service calls StoreAsync, captures the reference string, constructs a ClaimCheckMessage, and publishes it to the broker. The consuming handler โ whether a background service implementing BackgroundService, a MassTransit consumer, or an NServiceBus handler โ reads the reference from the incoming message and calls RetrieveAsync.
One consideration to get right: what happens when RetrieveAsync fails? This is different from the broker failing to deliver the message. If the broker delivered the message but the payload store is temporarily unavailable, the consumer must decide whether to retry the retrieval in place, return the message to the queue for redelivery, or route it to a dead letter queue after exhausting retries. The three approaches have very different operational characteristics, and the right choice depends on the SLA of the payload store relative to the SLA of the downstream business process.
For context on how the Outbox Pattern and message-driven architectures integrate with background processing pipelines in ASP.NET Core, see the ASP.NET Core Outbox Pattern: Enterprise Decision Guide. If your system uses fan-out topologies, the Competing Consumers Pattern covers how to design consumer groups that scale without message contention.
For the authoritative specification, the Claim-Check Pattern on Azure Architecture Center provides the formal definition along with cloud-specific implementation notes.
Key Trade-offs
| Dimension | Without Claim Check | With Claim Check |
|---|---|---|
| Broker message size | Full payload embedded | Token only (< 1 KB typical) |
| Consumer latency | Single broker hop | Broker hop + storage retrieval |
| Payload lifecycle | Tied to message TTL | Independently managed |
| Fan-out cost | Payload replicated per subscriber | Token replicated, payload fetched once |
| Failure domains | Broker only | Broker + payload store |
| Testability | Payload always present in message | IPayloadStore mockable in isolation |
| GDPR compliance | Complex (requires message tombstoning) | Straightforward (delete stored object) |
The trade-off is a deliberate one. You are accepting an additional operational dependency โ the payload store โ in exchange for a decoupled, scalable messaging architecture that does not accumulate size pressure over time.
Anti-Patterns to Avoid
Skipping the abstraction layer: Calling BlobServiceClient directly from a domain handler couples your business logic to a specific cloud SDK. When the team migrates storage providers or needs to swap in an in-memory implementation for tests, the ripple effect is large and painful.
Not managing payload TTLs: Payloads that are never cleaned up become a silent cost and compliance risk. Every stored payload should have an explicit lifecycle โ either deleted by the consumer post-processing, expired by a storage lifecycle policy, or archived to cold storage after a defined window.
Applying the pattern universally: This is the overengineering trap. Apply the Claim Check Pattern selectively, to payloads that genuinely cross the size threshold or require independent lifecycle management. Wrapping every 2 KB event in a storage indirection layer adds operational overhead without architectural benefit.
Forgetting delete-on-consume semantics: If consumers never clean up stored payloads, storage costs accumulate without bound. Define a clear contract upfront: who owns deletion? When does it happen? What is the retention window? These are decisions, not defaults.
FAQ
What is the Claim Check Pattern in ASP.NET Core? The Claim Check Pattern is a messaging design pattern where a large payload is stored in an external data store (such as Azure Blob Storage or Amazon S3) and only a lightweight reference token is published to the message broker. The ASP.NET Core consumer reads the token from the message and retrieves the full payload from the external store for processing.
When should I use the Claim Check Pattern instead of embedding data in a message? Use the Claim Check Pattern when payloads approach or exceed your broker's message size limit, when multiple consumers need the same data and fan-out duplication is wasteful, when the payload contains binary content, or when you need independent lifecycle management for the data (TTL enforcement, GDPR erasure, archival).
Does the Claim Check Pattern work with RabbitMQ and MassTransit in ASP.NET Core? Yes. The pattern is transport-agnostic. Whether you're using Azure Service Bus, RabbitMQ with MassTransit, Amazon SQS, or Apache Kafka, the core mechanism is the same: store the payload externally, publish the reference in the message, retrieve the payload in the consumer. The specific broker SDK or library you use does not affect the pattern's structure.
What is the best payload store for the Claim Check Pattern in .NET? Azure Blob Storage is the most common choice for teams running on Azure, offering native TTL support via lifecycle management policies and tight integration with Managed Identity for secure access. Amazon S3 is the equivalent on AWS. For structured JSON payloads, a dedicated database table with an indexed reference_id column can serve as the payload store. Choose based on the content type, access patterns, and the infrastructure your team already operates.
How do I handle payload retrieval failures in a consumer? There are three standard approaches: retry the retrieval in place with exponential back-off (best when storage transient failures are brief), return the message to the queue for redelivery (appropriate when the processing delay is tolerable), or route the message to a dead letter queue after exhausting retries (necessary when payload store availability cannot be guaranteed). The right approach depends on the payload store's SLAs and the downstream processing window.
Should the consumer always delete the payload after processing? Not necessarily. The right lifecycle model depends on the use case. For transient processing events (order confirmation, notification triggering), immediate deletion after processing is appropriate. For audit-critical payloads, archival to cold storage is preferable. For multi-consumer fan-out, deletion should happen only after all subscribers have confirmed processing โ or be managed by a lifecycle policy with a fixed TTL rather than consumer-driven deletion.
Does the Claim Check Pattern increase end-to-end latency? Yes, by adding a storage retrieval round-trip between message receipt and payload availability. For most asynchronous event-processing pipelines, this additional latency (typically < 100 ms for regional blob storage) is acceptable. For real-time processing pipelines with strict sub-second SLAs, evaluate whether the latency budget accommodates the extra hop.
How does the Claim Check Pattern relate to the Outbox Pattern? The patterns are complementary. The Outbox Pattern ensures at-least-once reliable event publishing by writing events to a database outbox before they are dispatched to the broker. The Claim Check Pattern handles the case where those events carry large payloads. In a complete event-driven architecture, you might use both: the Outbox Pattern for reliability guarantees and the Claim Check Pattern for payload management. They operate at different layers and do not conflict.






