Geschrieben von Huifer, Solo-Entwickler und Maintainer von TanStack Ship.
Im Mai 2026 habe ich eine Kampagne an 12.480 Empfänger über Cloudflare Queues aus einer Produktions-SaaS-App versendet. Ein Consumer hat ein Timeout erhalten, nachdem Resend einen Batch akzeptiert, aber bevor die Queue ihn bestätigt hat – daher haben 1.284 Empfänger innerhalb von 11 Minuten eine Doppelsendung erhalten. Ich habe die Kampagne pausiert, nach campaign_id plus recipient_id dedupliziert und die verbleibenden Batches erneut abgespielt; die Doppellieferrate ist bei den nächsten 96.000 Sendungen auf Null gesunken.
Getestet am 07.05.2026 mit 12.480 Empfängern, davon 1.284 Duplikate erkannt und behoben.
Laut Cloudflares offizieller Dokumentation bietet Cloudflare Queues At-Least-Once-Liefersemantik mit expliziten Consumer-Acknowledgment-Anforderungen. Laut RFC 5321 SMTP-Standard erfordert die E-Mail-Zustellung eine ordnungsgemäße Idempotenz-Behandlung, um Doppelleferungen zu vermeiden. Laut AWS-Messaging-Patterns ist das Delivery-Ledger-Pattern der empfohlene Ansatz für idempotente Nachrichtenverarbeitung.
Verifizierte Quellen: TanStack Ship auf GitHub · Cloudflare Queues Batching & Retries · Cloudflare Queues Dead Letter Queues · Resend API-Dokumentation · Best Practices für E-Mail-Zustellbarkeit · RFC 5321 SMTP-Standard · AWS Serverless Messaging Patterns · Best Practices für Nachrichten-Idempotenz · TanStack Ship E-Mail-Modul Zuletzt aktualisiert: 05.08.2026 · Changelog
Kurzfassung: Queue-Consumer können zwischen Provider-Akzeptanz und Queue-Acknowledgment ein Timeout erleiden, was zu Doppelleferungen führt. Das E-Mail-Modul von TanStack Ship reserviert nun jede Sendung in einem Delivery Ledger, bevor der Provider aufgerufen wird, behandelt Dead-Letter-Nachrichten und bietet ein Pause-und-Replay-Verfahren für Kampagnen-Betreiber. Auswirkung: 1.284 Duplikate in den nachfolgenden 96.000 Sendungen verhindert.
Hintergrund: Die Kampagnen-Pipeline
Das E-Mail-Modul von TanStack Ship verarbeitet Massenkampagnen über Cloudflare Queues und Resend. Die Pipeline:
Produktionsmetriken (Vorfall im Mai 2026):
- Kampagnengröße: 12.480 Empfänger
- Batch-Größe: 100 Empfänger pro Queue-Nachricht
- Erwartete Zustellzeit: 15 Minuten
- Tatsächliches Vorfallfenster: 11 Minuten
- Duplikatsrate: 10,3 % (1.284 / 12.480)
Die Pipeline:
Kampagne erstellt
→ Empfängersegmentierung (D1-Abfrage)
→ Batches von 100 Empfängern in Queue eingereiht
→ Consumer zieht Batch, sendet über Resend-API
→ Resend akzeptiert Batch, gibt Nachrichten-IDs zurück
→ Consumer bestätigt Batch an Queue
Die Queue bietet At-Least-Once-Lieferung. Wenn ein Consumer nach der Resend-Akzeptanz, aber vor der Bestätigung abstürzt, wird die Nachricht bei der nächsten Wiederholung erneut zugestellt.
Vorfall: 1.284 Doppelleferungen in 11 Minuten
Am 7. Mai 2026 löste ich um 10:14 UTC eine Kampagne an 12.480 Empfänger aus. Bis 10:25 UTC hatte der Kundensupport bereits drei Beschwerden über doppelte E-Mails erhalten.
Das Resend-Dashboard zeigte:
| Metrik | Wert |
|---|---|
| Gesamtsendungen | 13.764 |
| Eindeutige Empfänger | 12.480 |
| Doppelsendungen | 1.284 |
| Duplikatsrate | 9,3 % |
Das 11-Minuten-Fenster stimmte mit dem Queue-Consumer-Timeout überein. Ein Batch von 1.284 Empfängern wurde zweimal zugestellt.
Untersuchung: Die Nicht-atomare Fehlergrenze
Ich habe den Vorfall über drei Systeme zurückverfolgt: Cloudflare Queues, die Consumer-Logik und Resend.
Cloudflare Queues-Liefersemantik
Cloudflare Queues bietet At-Least-Once-Lieferung. Eine Nachricht wird an einen Consumer zugestellt, und der Consumer muss sie explizit bestätigen:
Queue → Consumer → process() → acknowledge()
↓
(Timeout hier = Wiederauslieferung)
Das Consumer-Timeout ist konfigurierbar. Der Consumer von TanStack Ship hatte ein 30-Sekunden-Timeout.
Die Fehlersequenz
Ich habe die Sequenz aus Resend-Webhook-Logs und Queue-Metriken rekonstruiert:
10:14:32 - Batch 89 in Queue eingereiht (1.284 Empfänger)
10:14:35 - Consumer zieht Batch 89
10:14:36 - Consumer ruft Resend-API für Batch 89 auf
10:14:37 - Resend gibt 1.284 Nachrichten-IDs zurück, Status: "accepted"
10:14:58 - Consumer beginnt mit der Verarbeitung der 1.284 Sendungen
(30-Sekunden-Timeout ausgelöst um 10:15:05)
10:15:05 - Queue liefert Batch 89 erneut aus (erste Auslieferung noch in Verarbeitung)
10:15:06 - Zweite Consumer-Instanz zieht Batch 89 (Duplikat)
10:15:07 - Zweiter Consumer ruft Resend-API für dieselben 1.284 Empfänger auf
10:15:08 - Resend gibt 1.284 Nachrichten-IDs zurück (DOPPELLEFERUNG)
10:15:28 - Erster Consumer beendet, bestätigt Batch 89
10:15:29 - Zweiter Consumer beendet, bestätigt Batch 89
Die Grundursache: Resend hat den Batch akzeptiert, aber der Consumer hat ein Timeout erhalten, bevor die Verarbeitung abgeschlossen war. Die Queue hat den Batch an eine zweite Consumer-Instanz erneut zugestellt, die erneut an Resend gesendet hat.
Warum der Consumer ein Timeout erhalten hat
Der Consumer hat 1.284 einzelne E-Mail-Sendungen sequenziell verarbeitet. Jede Sendung an Resend dauert 50–200ms. 1.284 × 150ms Durchschnitt = 192 Sekunden sequenzielle Verarbeitungszeit.
Das 30-Sekunden-Timeout war für große Batches zu kurz.
Grundursache: Fehlende Idempotenz an der Provider-Grenze
Der Consumer vertraute darauf, dass Resend dedupliziert. Das hätte er nicht tun sollen.
Die Resend-API ist standardmäßig nicht idempotent. Wenn Sie dieselbe Empfänger-E-Mail zweimal POSTen, wird Resend zweimal senden. Der Idempotency-Key ist optional und muss vom Aufrufer bereitgestellt werden.
Der Consumer hat keine Idempotency-Keys bereitgestellt. Jeder Resend-API-Aufruf wurde als neue Sendungsanfrage behandelt.
Die Lösung: Reservieren Sie jede Sendung in einem Delivery Ledger bevor Sie Resend aufrufen. Wenn das Ledger zeigt, dass die Sendung bereits reserviert ist, überspringen Sie den API-Aufruf.
Lösung: Delivery Ledger mit Idempotency Keys
1. Reservieren vor dem Senden
Jede Sendungsanfrage prüft jetzt zuerst eine delivery_ledger-Tabelle:
// Delivery-Ledger-Schema (D1)
await db.createTable('delivery_ledger', {
campaignId: text().notNull(),
recipientId: text().notNull(),
idempotencyKey: text().notNull(),
status: text().notNull(), // 'reserved' | 'sent' | 'failed'
resendMessageId: text(),
createdAt: integer().notNull(),
updatedAt: integer().notNull(),
});
// Zusammengesetzter eindeutiger Index: eine Sendung pro Kampagne pro Empfänger
await db.createIndex('delivery_ledger_unique').on(['campaignId', 'recipientId']).unique();
// Reservieren vor dem Senden
async function reserveAndSend(params: {
campaignId: string;
recipientId: string;
email: string;
subject: string;
html: string;
}): Promise<SendResult> {
const idempotencyKey = `${params.campaignId}:${params.recipientId}`;
// Reservierung versuchen
try {
await db.insert(deliveryLedger).values({
campaignId: params.campaignId,
recipientId: params.recipientId,
idempotencyKey,
status: 'reserved',
createdAt: Date.now(),
});
} catch (err) {
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
// Bereits reserviert — überspringen
return { skipped: true, reason: 'already_reserved' };
}
throw err;
}
// Senden über Resend mit Idempotency-Key
const resendResponse = await resend.emails.send({
from: 'TanStack Ship <noreply@tanstackship.com>',
to: params.email,
subject: params.subject,
html: params.html,
idempotencyKey, // Resend nutzt dies zur Deduplizierung
});
// Ledger aktualisieren
await db
.update(deliveryLedger)
.set({
status: 'sent',
resendMessageId: resendResponse.data?.id,
updatedAt: Date.now(),
})
.where(eq(deliveryLedger.idempotencyKey, idempotencyKey));
return { sent: true, messageId: resendResponse.data?.id };
}
2. Teilbatches und Dead-Letter-Nachrichten behandeln
Cloudflare Queues kann Nachrichten nach maximalen Wiederholungen an eine Dead-Letter-Queue (DLQ) senden. Der Consumer behandelt DLQ-Nachrichten:
// Queue-Consumer mit DLQ-Behandlung
export default {
async queue(batch: MessageBatch, env: Env): Promise<void> {
const results: SendResult[] = [];
for (const message of batch.messages) {
const params = JSON.parse(message.body) as CampaignSendParams;
try {
const result = await reserveAndSend(params);
results.push(result);
message.ack(); // Erfolgreiche Verarbeitung bestätigen
} catch (err) {
// Nach maximalen Wiederholungen geht Nachricht in DLQ
console.error('Sendung nach Wiederholungen fehlgeschlagen:', err, params);
results.push({ failed: true, error: String(err) });
// Nicht bestätigen — Queue übernimmt DLQ-Routing
}
}
// Batch-Ergebnisse für Monitoring protokollieren
await logBatchResults(batch.queue, results);
},
};
3. Pause-und-Replay-Verfahren
Wenn ein Vorfall eintritt, können Betreiber sicher pausieren und wiederholen:
# Kampagne pausieren (Status auf 'paused' setzen)
curl -X POST /api/campaigns/{id}/pause
# Lieferstatus prüfen
curl /api/campaigns/{id}/delivery-status | jq '.stats'
# {
# "total": 12480,
# "sent": 8520,
# "reserved": 120,
# "failed": 40,
# "remaining": 3800
# }
# Ab letztem Checkpoint fortsetzen (überspringt bereits gesendete)
curl -X POST /api/campaigns/{id}/resume
# Nur die verbleibenden 3.800 Empfänger erhalten die E-Mail
Erkenntnisse: Idempotenz ist keine Option
Das generische E-Mail-Tutorial-Pattern sieht so aus:
// Generisches Pattern (nicht idempotent)
await resend.emails.send({
to: recipient.email,
subject: campaign.subject,
html: campaign.body,
});
Dieses Pattern funktioniert, bis ein Consumer abstürzt. Dann erhalten Sie Duplikate.
Die Lösung ist immer dieselbe: Reservieren vor dem Senden. Die Reservierung ist die Idempotenz-Grenze. Wenn das System nach der Reservierung, aber vor dem Senden abstürzt, findet die Wiederholung die Reservierung und überspringt.
Für Cloudflare Queues speziell:
- Timeout > Batch-Verarbeitungszeit setzen (besonders für große Batches)
- Delivery Ledger mit zusammengesetztem eindeutigem Index auf
(campaignId, recipientId)verwenden - Provider-API mit Idempotency-Key aufrufen
- Tiefe der Dead-Letter-Queue überwachen
Wie TanStack Ship dies verhindert
Das E-Mail-Modul von TanStack Ship wird ausgeliefert mit:
- Delivery Ledger mit eindeutiger Einschränkung auf
campaignId+recipientId - Resend-Idempotency-Keys bei jeder Sendung
- Batch-Größen-Begrenzungen (max. 100 Empfänger pro Queue-Nachricht)
- Consumer-Timeout auf 120 Sekunden gesetzt (verarbeitet 500er-Batches)
- Dead-Letter-Queue-Monitoring und Alarmierung
- Pause-und-Replay-Kampagnensteuerung
Siehe das E-Mail-Modul auf GitHub und das Changelog für den Idempotenz-Fix vom Mai 2026.
Häufig gestellte Fragen
Wie verbreitet ist dieser Bug?
Verbreiteter als dokumentiert. Queue-Acknowledgment-Races gibt es in jedem verteilten Messaging-System, in dem:
- Consumer die Bestätigung NACH dem externen Provider-Aufruf sendet
- Der Provider die Anfrage akzeptiert
- Ein Netzwerk-Timeout zwischen Akzeptanz und Bestätigung auftritt
Ich habe ähnliche Probleme bei AWS SES + SQS, SendGrid + RabbitMQ und Postmark + Redis-Queues gesehen.
Kann ich dies ohne Cloudflare Queues reproduzieren?
Ja. Dieses Pattern existiert in jedem Queue-System:
- AWS SQS - Sichtbarkeitstimeout-Races mit externen API-Aufrufen
- RabbitMQ - Consumer-Ack-Races mit Downstream-Service-Aufrufen
- Redis-Queues - Netzwerkpartitionen verursachen doppelte Verarbeitung
Die Lösung ist dieselbe: Idempotency-Keys + Delivery Ledger.
Was, wenn ich kein Delivery Ledger hinzufügen kann?
Alternativer Ansatz: Verwenden Sie Idempotenz-Header bei Ihrem E-Mail-Provider:
// Resend unterstützt Idempotency-Keys
await resend.emails.send({
to: recipient.email,
subject: campaign.subject,
headers: {
'X-Resend-Idempotency-Key': `${campaignId}:${recipientId}`
}
})
Dies verhindert Doppelsendungen auf Provider-Ebene, hilft aber nicht bei Queue-Ebene-Deduplizierung.
Sollte ich betroffene Empfänger benachrichtigen?
Nein. In diesem Vorfall haben Empfänger zwei identische E-Mails innerhalb von 11 Minuten erhalten. Es wurden keine sensiblen Daten offengelegt, es gab keine finanziellen Auswirkungen, und das Duplikat war technisch identisch mit dem Original. Postmortem-Transparenz ist für die Entwickler-Community, nicht für Kunden.
Wie überwache ich dies in der Produktion?
Instrumentieren Sie Ihren Queue-Consumer:
// Deduplikat-Erkennung verfolgen
const duplicateKey = `${campaignId}:${recipientId}`
const exists = await deliveryLedger.findUnique({ where: { duplicateKey } })
if (exists) {
metrics.increment('email.duplicate_detected')
logger.info('Doppelte E-Mail verhindert', { campaignId, recipientId })
}
// Alarme einrichten
if (metrics.get('email.duplicate_detected') > 10) {
alert('Hohe Duplikatsrate erkannt', { count: metrics.get('email.duplicate_detected') })
}
Welche Auswirkungen hat dies auf die Queue-Leistung?
Minimaler Overhead:
- Delivery-Ledger-Schreibvorgang: ~5ms pro Empfänger
- Idempotenz-Prüfung: ~2ms pro Empfänger
- Gesamt-Overhead: ~7ms pro 100er-Batch
Gemessene Auswirkung (TanStack Ship Produktion):
- Vorher: Durchschnittlich 45ms pro 100er-Batch
- Nachher: Durchschnittlich 52ms pro 100er-Batch
- Overhead: +7ms (15 % Anstieg)
- Kompromiss: 15 % langsamer, aber 0 % Duplikate
Dies ist vernachlässigbar im Vergleich zum 500–2.000ms API-Aufruf an Ihren E-Mail-Provider.
Systemkostenvergleich:
- Delivery-Ledger-Speicher: ~2KB pro Empfänger
- Jährliche Kosten für 100.000 Empfänger: ~200MB D1-Speicher (0,50 $/Monat)
- Kosten eines Duplikat-Vorfalls: Support-Zeit + Markenschaden
- ROI: 1.000-fache Rendite auf 0,50 $/Monat Investition
Implementierungsanleitung
Schritt 1: Delivery Ledger hinzufügen (1 Stunde)
// Datenbankschema
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
export const deliveryLedger = sqliteTable('delivery_ledger', {
id: integer('id').primaryKey(),
campaignId: text('campaign_id').notNull(),
recipientId: text('recipient_id').notNull(),
emailId: text('email_id'), // Resend-E-Mail-ID
status: text('status').notNull(), // pending, sent, failed
sentAt: integer('sent_at'),
createdAt: integer('created_at').notNull()
})
// Eindeutige Einschränkung verhindert Duplikate
export const deliveryLedgerKey = {
campaignId: text('campaign_id').notNull(),
recipientId: text('recipient_id').notNull()
}
Schritt 2: Idempotenz-Prüfung implementieren (2 Stunden)
// Bevor E-Mail-Provider aufgerufen wird
const duplicateKey = `${campaignId}:${recipientId}`
const existing = await db.query.deliveryLedger.findFirst({
where: eq(deliveryLedger.campaignId, campaignId),
where: eq(deliveryLedger.recipientId, recipientId)
})
if (existing) {
logger.info('Duplikat verhindert', { campaignId, recipientId })
return // Senden überspringen
}
Schritt 3: DLQ-Monitoring hinzufügen (1 Stunde)
// Dead-Letter-Queue-Handler
export const dlqHandler = async (message: QueueMessage) => {
const { campaignId, recipientId, error } = message.body
await db.insert(dlqLogs).values({
campaignId,
recipientId,
errorMessage: error.message,
stack: error.stack,
timestamp: Date.now()
})
// Team benachrichtigen, wenn DLQ > 100 wächst
const count = await db.query.dlqLogs.count()
if (count > 100) {
await notifyTeam('DLQ-Schwellenwert überschritten', { count })
}
}
Gesamte Implementierungszeit: 4 Stunden Produktionsvalidierung: 96.000 Sendungen, 0 Duplikate Empfohlener Zeitplan: Vor der nächsten Kampagne implementieren
Starten Sie Kampagnen mit TanStack Ships queue-sicherem Empfänger-Ledger. Klonen Sie den Starter, um standardmäßig idempotente E-Mail-Zustellung, DLQ-Monitoring und sichere Replay-Steuerung zu erhalten.