---
title: 'Claim Check Pattern'
source: 'https://academia.sh/en/courses/resilience-patterns/claim-check-pattern'
course: 'Resilience and Reliability'
language: en
updated: '2026-08-23T07:01:29+00:00'
license: 'CC BY-SA 4.0'
---

# Claim Check Pattern

Moving a large payload outside the message: proof of delivery raising the message to 40,220 bytes, the claim check lowering message bytes by 141.6 times, the queue's backlog dropping from 3016.5 MB to 21.3 MB, the proof never shrinking on the storage side, and the orphaned payload and dangling claim check that splitting a single write into two brings.

The previous two lessons kept the workflow's steps correct: duplication was suppressed,
half-finished steps were balanced. The **data** the workflow carries was never in question. When
the carrier reports a delivery, it attaches proof of delivery alongside it — the recipient's
signature or a photo of the delivery point. This addition moves K01's 220-byte state event record
to a different order of magnitude, and that byte count passes through the queue, gets copied to
every consumer, and is carried again on every redelivery.

The **claim check** is a layout that writes the large payload to a separate store and lets the
message carry only a reference to that payload. This lesson's measures are four: the message's
size, the bytes crossing the queue, how long the payload lives in a separate store, and the check
dangling — the payload deleted, the check still in hand.

## Two Writes, No Shared Transaction Boundary

**DD8 — proof of delivery is 40,000 bytes.** Rationale: the proof is a compressed signature
image, two orders of magnitude above K01's 900-byte shipment record. Its sensitivity is already
visible in the message byte ratio. **DD9 — a claim check record is 64 bytes**: the store name,
the object key, and a digest.

The pattern's hidden cost starts here. The attached layout has a single write: the message goes
to the queue and the proof is inside it. The claim-check layout has **two writes** — the proof to
the store first, then the message to the queue — and the two share no transaction boundary. This
is exactly The Data Access Layer and Business Logic course's distributed transaction problem. Two
new failure modes are born: the **orphaned payload** (the proof was written, the message was not)
and the **dangling claim check** (the message is in hand, the proof is deleted).

```js
// claim-check/queue.mjs — the in-process model of the event stream carrying proof of delivery.
// There is no real queue, store, or network: the queue is an array, the payload store a set, bytes are counted.

export const EVENT = 220, PROOF = 40_000, CHECK = 64;   // K01 V6; DD8 proof; DD9 check record

export function generator(seed) {              // 32-bit linear congruential generator, seed exposed
  let s = seed >>> 0;
  return () => { s = (Math.imul(s, 1103515245) + 12345) >>> 0; return s / 4294967296; };
}

// layout:   "attached" = the proof travels inside the message; "check" = the proof sits in a store, the message carries a check
// deletion: "immediate" = the consumer deletes on processing; "lifetime" = deletion waits out a lifetime
export function run({ n, layout, deletion, producerCrash, unackedCrash, seed }) {
  const rnd = generator(seed);
  const usesCheck = layout === "check";
  const message = usesCheck ? EVENT + CHECK : EVENT + PROOF;
  const store = new Set(), queue = [];
  const s = { message, bytesMoved: 0, deliveries: 0, lostEvents: 0, orphanedPayload: 0,
    danglingCheck: 0, processed: 0, remainingInStore: 0 };

  for (let i = 0; i < n; i += 1) {
    if (usesCheck) store.add(i);                        // the payload is written first
    const crashed = rnd() < producerCrash;              // then the message is written; a crash can land in between
    if (crashed) { if (usesCheck) s.orphanedPayload += 1; else s.lostEvents += 1; continue; }
    queue.push({ no: i, attempt: 0 });
  }
  while (queue.length > 0) {
    const m = queue.shift();
    s.bytesMoved += message; s.deliveries += 1;          // every delivery carries the message from scratch
    if (usesCheck && !store.has(m.no)) { s.danglingCheck += 1; continue; }
    s.processed += 1;
    if (usesCheck && deletion === "immediate") store.delete(m.no);
    if (rnd() < unackedCrash && m.attempt === 0) queue.push({ no: m.no, attempt: 1 });
  }
  s.remainingInStore = usesCheck ? store.size : 0;
  return s;
}
```

The failure scenario is named directly. **DD10 — in the failure window, the probability that the
producer crashes between its two writes is 0.005, the probability that the consumer crashes
before acknowledgment is 0.01.** Rationale: the first window is the short gap between two remote
writes, the second is the gap between processing and acknowledgment, and the second is longer.
Both are assumptions, not added to K01's table.

```js
// claim-check/measure.mjs — four layouts, without and with failure; message bytes and dangling counted
import { run, EVENT, PROOF, CHECK } from "./queue.mjs";

const N = 100_000;
const CONDITION = [["failure-free day", 0, 0], ["failure window (DD10)", 0.005, 0.01]];
const LAYOUT = [["attached", "-"], ["check", "immediate"], ["check", "lifetime"]];

console.log(`message bytes: attached ${EVENT + PROOF}, check ${EVENT + CHECK} ` +
  `(K01 V6 = ${EVENT} bytes event, DD8 = ${PROOF} bytes proof, DD9 = ${CHECK} bytes check)`);
for (const [name, producerCrash, unackedCrash] of CONDITION) {
  console.log(`\n${name}: ${N} delivery events, producer crash ${producerCrash}, unacked crash ${unackedCrash}`);
  console.log(`${"layout".padEnd(18)}${"deliveries".padStart(11)}${"MB moved".padStart(11)}` +
    `${"lost events".padStart(13)}${"orphaned".padStart(11)}${"dangling".padStart(11)}` +
    `${"processed".padStart(11)}${"in store".padStart(10)}`);
  for (const [layout, deletion] of LAYOUT) {
    const r = run({ n: N, layout, deletion, producerCrash, unackedCrash, seed: 20260730 });
    console.log(`${`${layout} ${deletion}`.padEnd(18)}${String(r.deliveries).padStart(11)}` +
      `${(r.bytesMoved / 1e6).toFixed(2).padStart(11)}${String(r.lostEvents).padStart(13)}` +
      `${String(r.orphanedPayload).padStart(11)}${String(r.danglingCheck).padStart(11)}` +
      `${String(r.processed).padStart(11)}${String(r.remainingInStore).padStart(10)}`);
  }
}
```

```
message bytes: attached 40220, check 284 (K01 V6 = 220 bytes event, DD8 = 40000 bytes proof, DD9 = 64 bytes check)

failure-free day: 100000 delivery events, producer crash 0, unacked crash 0
layout             deliveries   MB moved  lost events   orphaned   dangling  processed  in store
attached -             100000    4022.00            0          0          0     100000         0
check immediate        100000      28.40            0          0          0     100000         0
check lifetime         100000      28.40            0          0          0     100000    100000

failure window (DD10): 100000 delivery events, producer crash 0.005, unacked crash 0.01
layout             deliveries   MB moved  lost events   orphaned   dangling  processed  in store
attached -             100495    4041.91          492          0          0     100495         0
check immediate        100495      28.54            0        492        987      99508       492
check lifetime         100495      28.54            0        492          0     100495    100000
```

These numbers are in the **measurement** class: they come from a process-internal model and a
generator seeded at 20260730.

## The Deletion Policy Is a Correctness Decision

The top table gives the message bytes: the attached layout moves 4022.00 MB for a hundred
thousand events, the claim-check layout 28.40 MB. The ratio is the message byte ratio, and with
no failure there is no other difference.

The bottom table makes the real distinction, and the column that deserves attention is
`processed`. In the attached layout, the producer's crash **drops 492 events entirely** — the
proof and the event were in a single write, and both were gone together. In the claim-check
layout, the same crash still loses the events, but it leaves 492 orphaned payloads in the store;
the loss is equal, and there is now garbage on top of it.

The two deletion policies behave differently under the same failure. Under **immediate
deletion**, 987 redeliveries cannot find the proof and turn into dangling checks; the processed
count sits at 99,508, meaning every event is processed **exactly once**. Under **lifetime-based
deletion**, dangling checks are zero, but processed is 100,495: all 987 redeliveries find the
proof and redo the work.

This is the pattern's least-noticed consequence. The moment the payload is deleted turns, without
anyone intending it, into an idempotency check: immediate deletion blocks duplicate processing
but turns redelivery into an error; lifetime-based deletion runs the redelivery and hands the
duplicated side effect off to **the first lesson's ledger**. The correct setup keeps the two
separate — the idempotency decision is made in the ledger, the deletion decision in the store; the
payload's lifetime is not used as a correctness mechanism.

## Back to the Numbers

**DD11 — proof lifetime is 7 days.** Rationale: a delivery dispute arrives within the business day
following the delivery day, and the proof is requested inside that window; proof is not kept
without a dispute. Its sensitivity is given at 1 and 30 days.

```js
// claim-check/cost.mjs — converting the measured message bytes into queue, storage, and dangling numbers at K01 scale
const V3 = 400_000, V8 = 3, DAY_SEC = 86_400;                     // K01: daily shipments, peak factor
const WRITE_MBIT = 0.17, GROWTH_MB = 976, STORAGE_GB = 712.48;    // K01 computed values
const BACKLOG = 525_000, EVENT_SHARE = 7;                         // M19/K03 message queues lesson: highest backlog
const ATTACHED = 40_220, CHECKED = 284, PROOF = 40_000;           // measure.mjs measurement; DD8
const peakDeliveries = (V3 / DAY_SEC) * V8;                       // every shipment produces one delivery event

console.log(`peak delivery events ${peakDeliveries.toFixed(4)} events/s (K01: ${V3} shipments/day, peak factor ${V8})`);
console.log(`\n${"layout".padEnd(12)}${"message bytes".padStart(14)}${"queue Mbit/s".padStart(15)}` +
  `${"of K01 write entry".padStart(21)}${"backlog MB".padStart(13)}`);
for (const [name, b] of [["attached", ATTACHED], ["check", CHECKED]]) {
  const mbit = (peakDeliveries * b * 8) / 1e6, backlog = ((BACKLOG / EVENT_SHARE) * b) / 1e6;
  console.log(`${name.padEnd(12)}${String(b).padStart(14)}${mbit.toFixed(4).padStart(15)}` +
    `${`${(mbit / WRITE_MBIT).toFixed(2)}x`.padStart(21)}${backlog.toFixed(1).padStart(13)}`);
}
console.log(`queue byte ratio ${(ATTACHED / CHECKED).toFixed(1)}x; ` +
  `backlog ${(BACKLOG / EVENT_SHARE).toLocaleString("en-US")} messages with proof (M19/K03: ${BACKLOG.toLocaleString("en-US")} events / ${EVENT_SHARE})`);

console.log(`\n${"DD11 lifetime".padStart(15)}${"proof in store".padStart(16)}${"store GB".padStart(11)}` +
  `${"of K01 storage".padStart(16)}${"of daily growth".padStart(17)}`);
for (const days of [1, 7, 30]) {
  const count = V3 * days, gb = (count * PROOF) / 1e9;
  console.log(`${`${days} d`.padStart(15)}${count.toLocaleString("en-US").padStart(16)}` +
    `${gb.toFixed(2).padStart(11)}${`${((100 * gb) / STORAGE_GB).toFixed(2)}%`.padStart(16)}` +
    `${`${((gb * 1000) / GROWTH_MB).toFixed(2)}x`.padStart(17)}`);
}

const DANGLING = 987 / 100_000, ORPHANED = 492 / 100_000;         // measure.mjs measurement
console.log(`\nfailure window at K01 scale (${V3.toLocaleString("en-US")} delivery events/day):`);
console.log(`dangling checks ${(V3 * DANGLING).toFixed(0)}/day, orphaned payloads ${(V3 * ORPHANED).toFixed(0)}/day`);
console.log(`orphaned payload bytes ${((V3 * ORPHANED * PROOF) / 1e6).toFixed(1)} MB/day, ` +
  `without a lifetime, a year gives ${((V3 * ORPHANED * PROOF * 365) / 1e9).toFixed(2)} GB`);
console.log(`in the attached layout, the same crash drops ${(V3 * ORPHANED).toFixed(0)} events entirely`);
```

```
peak delivery events 13.8889 events/s (K01: 400000 shipments/day, peak factor 3)

layout       message bytes   queue Mbit/s   of K01 write entry   backlog MB
attached             40220         4.4689               26.29x       3016.5
check                  284         0.0316                0.19x         21.3
queue byte ratio 141.6x; backlog 75,000 messages with proof (M19/K03: 525,000 events / 7)

  DD11 lifetime  proof in store   store GB  of K01 storage  of daily growth
            1 d         400,000      16.00           2.25%           16.39x
            7 d       2,800,000     112.00          15.72%          114.75x
           30 d      12,000,000     480.00          67.37%          491.80x

failure window at K01 scale (400,000 delivery events/day):
dangling checks 3948/day, orphaned payloads 1968/day
orphaned payload bytes 78.7 MB/day, without a lifetime, a year gives 28.73 GB
in the attached layout, the same crash drops 1968 events entirely
```

## The Numbers for Two Days

**The failure-free day's cost has three line items.** Every delivery event now carries two writes
and one extra read on the consumer side; the single write's atomicity is gone. At DD11 = 7 days,
the proof sits in the store at 112.00 GB, 15.72 percent of K01's 712.48 GB storage line. And on
the storage side, the pattern has **no gain at all**: the proof occupies the same 40,000 bytes
whether it is attached or checked. The claim check is a **transport** decision, not a storage
decision.

**The failure day's gain sits in the queue.** In the attached layout, the peak delivery flow puts
4.4689 Mbit/s on the queue — 26.29 times K01's 0.17 Mbit/s write entry. In the claim-check layout,
it is 0.0316 Mbit/s, 0.19 times the write entry. The real difference shows up in the backlog:
given that one in seven of the 525,000 events The Application Layer and Service Interaction
course measured is a message carrying proof, the queue has to hold **3016.5 MB** in the attached
layout and **21.3 MB** in the claim-check layout. In a design where the queue is a bounded buffer,
this 141.6-fold difference is the difference in how many events the same bound can hold.

The gain's counterpart is new failure modes. At K01 scale, the failure window produces 1968
orphaned payloads a day (78.7 MB) and, under the immediate deletion policy, 3948 dangling checks.
Without a lifetime, orphaned payloads climb to 28.73 GB a year; **DD11 is not a storage
parameter, it is a garbage collection mechanism**. Dangling checks, on the other hand, are not
data loss — all 3948 are redeliveries of work already processed — but they show up as errors on
the consumer side, and if they are not routed off the dead-letter path, they hide real errors.

## Summary

- Proof of delivery raises the message from 220 bytes to 40,220 bytes; the claim check brings it
  down to 284 bytes, a ratio of 141.6x.
- Queue load: the peak delivery flow is 4.4689 Mbit/s in the attached layout (26.29 times K01's
  write entry), 0.0316 Mbit/s in the claim-check layout (0.19 times).
- The difference grows in the backlog: a backlog of 75,000 messages with proof holds 21.3 MB
  instead of 3016.5 MB.
- The pattern has no gain on the storage side: at DD11 = 7 days the proof sits at 112.00 GB, 15.72
  percent of K01's storage; the claim check is a transport decision, not a storage decision.
- The pattern splits a single write into two and introduces two new failure modes: 1968 orphaned
  payloads a day (78.7 MB, 28.73 GB a year without a lifetime) and 3948 dangling checks.
- The deletion policy is silently a correctness decision: under immediate deletion, processed is
  99,508 (exactly once); under lifetime-based deletion, it is 100,495 (987 duplicate processing
  runs); the latter depends on the idempotency ledger.

## Next Step

The proof left the queue, but the whole path still runs through the service. The carrier uploads
the proof to the service, the service writes it to the store; when a recipient wants to see the
proof, the service reads it from the store and passes it to the client. The 40,000 bytes removed
from the queue still pass through the service's request path twice, and when the store slows
down, they tie up the service's own workers. The next lesson takes on removing this byte count
from the service as well: the limited-time authorization granted for direct client access to the
resource, the byte count that never passes through the service, the authorization window it
opens, and what narrowing that scope costs.
