# Preventing Duplicate Sends

Source: https://wraps.dev/docs/guides/idempotency

SQS is at-least-once. Your worker will eventually send the same email twice unless you stop it, and the day it happens will be the day you are sending password resets.

## Where the duplicates come from

There is no bug to find. Every one of these is a queue or a runtime behaving exactly as documented:

-   A Lambda times out after SES accepted the message but before the handler returned. SQS never got the ack, so the message comes back.
-   Your handler takes longer than the queue's visibility timeout. SQS hands the same message to a second consumer while the first is still working.
-   A deploy kills a worker mid-batch. Everything it had not acknowledged is redelivered.
-   An upstream service retries a webhook it never saw a 200 for, and enqueues the job again.

Wraps does not deduplicate this for you

`email.send()` takes no idempotency key today. Calling it twice with identical arguments sends two emails, and SES will accept both and return two different message ids. There is nothing in the SDK or in SES that notices they are the same message.

Workflows are the exception. A workflow step deduplicates internally on an execution and step id, so a retried workflow does not re-send a step that already completed. That protection does not extend to direct `send()` calls from your own workers, which is what this page is about.

## Pick a key that describes the outcome

The whole design is in the key. It has to identify the thing that should happen exactly once, and it has to be identical across every retry of that thing.

| Key | Verdict |
| --- | --- |
| `receipt:{orderId}:{userId}` | Good. Survives redelivery, names one real-world event. |
| `password-reset:{tokenId}` | Good. One token, one email, however many times the job runs. |
| `{sqsMessageId}` | Useless. A redelivered message keeps its id, but a re-enqueued job gets a new one, and that is the case you were worried about. |
| `hash(subject + body)` | Dangerous. Two customers legitimately getting the same notification collide, and the second one silently gets nothing. |

## The dedupe table

A conditional write against DynamoDB is enough. The condition is the lock: two workers racing on the same key cannot both succeed, so you do not need any other coordination.

dedupe.ts

```
// One DynamoDB table, one attribute, a TTL. That is the whole mechanism.////   Table:         email-sends//   Partition key: dedupeKey (String)//   TTL attribute: expiresAtimport { DynamoDBClient } from "@aws-sdk/client-dynamodb";import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));/** * Returns true the first time it sees a key, false every time after. * The conditional write is the lock — DynamoDB rejects the second one, so * two workers racing on the same message cannot both win. */async function claim(dedupeKey: string): Promise<boolean> {  try {    await ddb.send(      new PutCommand({        TableName: "email-sends",        Item: {          dedupeKey,          claimedAt: Date.now(),          // Keep the row well past the longest retry window you allow.          expiresAt: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7,        },        ConditionExpression: "attribute_not_exists(dedupeKey)",      }),    );    return true;  } catch (error) {    if ((error as { name?: string }).name === "ConditionalCheckFailedException") {      return false;    }    throw error;  }}
```

Use your own table rather than the `wraps-email-*`tables the CLI deploys. Those hold delivery history and Wraps manages their schema and retention; a dedupe ledger is your application's state and should outlive any decision we make about ours.

## In the worker

worker.ts

```
import { WrapsEmail } from "@wraps.dev/email";const email = new WrapsEmail({ region: "us-east-1" });export async function handler(event: { Records: { body: string }[] }) {  for (const record of event.Records) {    const job = JSON.parse(record.body) as {      userId: string;      orderId: string;      to: string;    };    // The key names the thing that should happen once, not the attempt.    // "order 8891's receipt to this user" happens once. A message id would    // change on redelivery and defeat the whole exercise.    const dedupeKey = `receipt:${job.orderId}:${job.userId}`;    if (!(await claim(dedupeKey))) {      // Already sent on an earlier attempt. Ack and move on.      continue;    }    await email.send({      from: "receipts@yourdomain.com",      to: job.to,      subject: `Receipt for order ${job.orderId}`,      html: "<p>Thanks for your order.</p>",    });  }}
```

## Claim before you send, and know what that costs

There is no ordering that is safe in every case, because the send and the bookkeeping are not one atomic operation and cannot be made into one. You are choosing which failure you would rather have.

ordering.ts

```
// Claim first, send second. This order is deliberate.//// claim() then send()  →  a crash between them loses one email.// send() then claim()  →  a crash between them sends a second one.//// Losing a receipt is a support ticket. Sending a password reset twice is a// security question. Pick the failure you would rather explain, and for// almost every transactional email that means claiming first.//// If you cannot lose the message, do not reach for a distributed// transaction. Record the intent in your own database inside the// transaction that created the order, and let a separate worker drain it.// That is the outbox pattern and it is the only honest answer here.
```

## Two settings that cause more duplicates than anything else

### Visibility timeout

Set it to at least six times your function timeout, which is what AWS recommends for Lambda consumers. A visibility timeout shorter than the work is a duplicate generator: the queue hands the job to a second consumer while the first is still sending.

### Batch item failures

Report partial batch failures so one bad record does not redeliver the nine beside it that already sent. Without it, a batch of ten where the last one throws re-sends the first nine.

## Verify it

-   Call your handler twice with the same payload. One email should arrive.
-   Throw after the send on purpose. The message redelivers, the claim fails, and no second email goes out.
-   Run two consumers against the same message at once. Exactly one should win the conditional write.

## Next steps

Workflows handle step-level deduplication for you. The events guide covers the queue and dead letter queue the CLI deploys.

[Building workflows](https://wraps.dev/docs/guides/workflows)[Event infrastructure](https://wraps.dev/docs/infrastructure/events)
