Replay Strategy #

In distributed systems, failure isn’t a possibility — it’s a certainty that’s just waiting for its moment. Networks will time out, services will go down, consumers will crash mid-process, and databases will occasionally get overloaded. The relevant question isn’t “will our system fail?” but “when it fails, what happens to the data and processes in flight?” Replay Strategy is the answer to that question: the system’s ability to reprocess failed events, messages, or requests — safely, in a controlled way, and without unwanted side effects. This article covers why replay matters, five types of replay with different purposes, three main challenges to anticipate, and implementation best practices with concrete code.

What Is a Replay Strategy? #

A Replay Strategy is a system design approach for reprocessing events, messages, or requests that failed, didn’t finish, or need to be re-verified, with the guarantee that repeating them won’t corrupt state or cause unwanted side effects.

What separates a Replay Strategy from merely “adding retry”: replay is an architectural decision, not a few lines of for i < 3 { retry() }. It covers how events are stored, how ordering is preserved, how duplication is handled, and how operators control the replay process in production.

Replay can happen at various layers of a system:

LayerReplay ExampleCommon Trigger
Message QueueSQS, RabbitMQ retry when a consumer failsConsumer crash, timeout
Event StreamingKafka consumer resets offset to an earlier positionBug fix after deploy
HTTP / APIRetrying a timed-out request to a downstreamNetwork instability
Background JobRerunning a scheduled job that crashed midwayOOM, pod restart
CQRS CommandReplaying a command that failed to executeUpstream validation error
Event SourcingRebuilding entire state from the event logSchema migration, calculation bug fix
flowchart TD
    A["Event / Request Arrives"] --> B{Processed Successfully?}
    B -- Yes --> C[Done ✓]
    B -- No --> D{Failure Type?}
    D -- Transient\nshort timeout --> E[Immediate Retry]
    D -- Needs time\nto recover --> F[Exponential Backoff]
    D -- Max retries\nexceeded --> G[Dead Letter Queue]
    D -- Bug fix\ndeployed --> H["Manual Replay\nEvent Store"]
    D -- Rebuild state --> I["Event Sourcing\nReplay"]
    E --> B
    F --> B
    G --> J[Alert + Review]
    J --> H

Why Replay Strategy Is Critical #

There are three fundamental reasons replay isn’t an optional feature in modern systems.

Transient failures are the norm, not the exception. An overloaded downstream service, a briefly dropped database connection, a rate limit from a third-party API — these are all temporary failures that should be recoverable automatically. Without replay, every transient failure risks becoming permanent data loss.

Replay is the foundation of at-least-once delivery. Almost all modern message brokers — Kafka, SQS, RabbitMQ, Pub/Sub — guarantee at-least-once delivery, not exactly-once. That means the system must be ready to receive the same event more than once, and must be able to “redo” a process after a failure. Without a proper replay strategy, the system only works under ideal conditions.

Replay is an extremely valuable operational tool. This is the often-overlooked part: replay isn’t just for error recovery. Engineers use replay to reprocess events after a bug fix is deployed, to rebuild read models after a schema migration, to re-sync data after an incident, and to re-audit suspicious transactions.

sequenceDiagram
    participant Prod as Event Producer
    participant Queue as Message Queue
    participant Consumer
    participant DB

    Prod->>Queue: Publish event (order.created)
    Queue->>Consumer: Deliver event
    Consumer->>DB: Process — DB timeout!
    DB--xConsumer: Error
    Consumer-->>Queue: NACK — no ack
    Queue->>Consumer: Redeliver (retry #1)
    Consumer->>DB: Process again
    DB-->>Consumer: OK
    Consumer-->>Queue: ACK ✓
    Note over Prod,DB: Without replay: event lost.\nWith replay: event stays safe.

The fundamental difference between a mature system and an immature one: a mature system assumes failure will happen and designs replay from the start. An immature system only adds replay after the first incident — and often too late, because the events are already gone.


Types of Replay Strategies #

Not all replay is the same. Each type has its own use case, strengths, and risks. Choosing the right type depends on the failure context and the system’s characteristics.

1. Immediate Retry #

Replay happens right after the failure, without delay. Suitable for failures that are truly transient and short — a momentarily dropped TCP connection, lock contention that releases quickly.

// ANTI-PATTERN: unlimited retry, no delay — bombarding the downstream
func processEvent(event Event) error {
    for {
        err := handler.Process(event)
        if err == nil {
            return nil
        }
        // if the downstream is down, this loops forever and makes things worse
    }
}

// CORRECT: retry with a clear maximum
func processWithRetry(event Event, maxAttempts int) error {
    var lastErr error
    for attempt := 1; attempt <= maxAttempts; attempt++ {
        lastErr = handler.Process(event)
        if lastErr == nil {
            return nil
        }
        log.Warnf("attempt %d/%d failed for event %s: %v",
            attempt, maxAttempts, event.ID, lastErr)
    }
    return fmt.Errorf("all %d attempts failed: %w", maxAttempts, lastErr)
}
Immediate retry is only safe for failures that recover within milliseconds. Don’t use immediate retry if the downstream needs more than a few seconds to recover — use exponential backoff.

2. Delayed Retry with Exponential Backoff #

Replay happens after a delay that grows with each failure. This gives the downstream service time to recover without being bombarded by a retry storm.

// ANTI-PATTERN: fixed delay — not adaptive to how long the failure lasts
func processWithFixedRetry(event Event) error {
    for i := 0; i < 5; i++ {
        if err := handler.Process(event); err == nil {
            return nil
        }
        time.Sleep(1 * time.Second) // always 1 second, no matter how long it's down
    }
    return errors.New("max retries exceeded")
}

// CORRECT: exponential backoff with jitter prevents thundering herds
func processWithBackoff(ctx context.Context, event Event) error {
    baseDelay := 1 * time.Second
    maxDelay  := 5 * time.Minute
    maxAttempts := 8

    for attempt := 1; attempt <= maxAttempts; attempt++ {
        err := handler.Process(event)
        if err == nil {
            return nil
        }

        if attempt == maxAttempts {
            return fmt.Errorf("exhausted %d attempts: %w", maxAttempts, err)
        }

        // Delay: 1s, 2s, 4s, 8s, 16s, 32s, 60s (capped)...
        delay := baseDelay * time.Duration(1<<uint(attempt-1))
        if delay > maxDelay {
            delay = maxDelay
        }

        // Jitter ±25%: so not all consumers retry at exactly the same time
        jitter := time.Duration(rand.Int63n(int64(delay / 4)))
        if rand.Intn(2) == 0 {
            delay += jitter
        } else {
            delay -= jitter
        }

        log.Infof("retry attempt %d in %v for event %s", attempt+1, delay, event.ID)

        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(delay):
        }
    }
    return nil
}

The delay pattern with backoff:

AttemptBase DelayWith Jitter (±25%)
1 → 21 second0.75 – 1.25 seconds
2 → 32 seconds1.5 – 2.5 seconds
3 → 44 seconds3 – 5 seconds
4 → 58 seconds6 – 10 seconds
5 → 616 seconds12 – 20 seconds
6 → 732 seconds24 – 40 seconds
7 → 860 seconds (capped)45 – 75 seconds

3. Dead Letter Queue (DLQ) #

When an event has exceeded its retry limit and still fails, it’s moved to a Dead Letter Queue — a separate queue for problematic events that can’t be processed right now. A DLQ is the safety net that ensures events don’t get lost.

flowchart LR
    A[Main Queue] --> B[Consumer]
    B -- Fails --> C[Retry 1]
    C -- Fails --> D[Retry 2]
    D -- Fails --> E["Retry 3\nMax Reached"]
    E --> F[Dead Letter Queue]
    F --> G["Alert / Monitor"]
    G --> H{"Root Cause\nFound?"}
    H -- Yes, fix deployed --> I["Manual Replay\nfrom DLQ"]
    H -- Needs investigation --> J["Engineer Review\nEvent Details"]
    I --> A
// ANTI-PATTERN: dropping the event on failure — losing data
func (c *Consumer) handleMessage(msg Message) error {
    if err := c.process(msg); err != nil {
        log.Errorf("failed to process %s, dropping: %v", msg.ID, err)
        return nil // event lost forever
    }
    return nil
}

// CORRECT: send to the DLQ with full metadata, don't drop the event
func (c *Consumer) handleWithDLQ(ctx context.Context, msg Message) error {
    err := c.processWithBackoff(ctx, msg)
    if err == nil {
        return nil
    }

    // Enrich with metadata for later debugging
    dlqMsg := DLQMessage{
        OriginalMessage: msg,
        FailureReason:   err.Error(),
        AttemptCount:    msg.ApproximateReceiveCount,
        FailedAt:        time.Now(),
        ConsumerVersion: c.version,
        StackTrace:      debug.Stack(),
    }

    if dlqErr := c.dlqQueue.Send(ctx, dlqMsg); dlqErr != nil {
        // DLQ send failed too — log CRITICAL, needs immediate attention
        log.Criticalf("FAILED to send to DLQ, event %s will be lost: %v",
            msg.ID, dlqErr)
        return dlqErr
    }

    log.Warnf("event %s sent to DLQ after %d attempts: %v",
        msg.ID, msg.ApproximateReceiveCount, err)
    return nil // ack the main message so it doesn't loop in the main queue
}

4. Manual Replay from the Event Store #

Events are stored durably and can be selectively replayed at any time — by timestamp, event ID, topic/partition, or specific business criteria.

// ANTI-PATTERN: replay without throttling — flooding the downstream all at once
func replayAllFromDLQ() {
    events := dlq.GetAll() // could be millions of events
    for _, e := range events {
        process(e) // all processed as fast as possible — the system could go down
    }
}

// CORRECT: controlled replay with a rate limiter and progress tracking
func (r *ReplayService) ReplayFromDLQ(ctx context.Context, opts ReplayOptions) error {
    log.Infof("starting replay: from=%v to=%v filter=%s rate=%f/s dryRun=%v",
        opts.From, opts.To, opts.Filter, opts.RatePerSecond, opts.DryRun)

    events, err := r.dlqStore.Query(ctx, opts.From, opts.To, opts.Filter)
    if err != nil {
        return fmt.Errorf("query DLQ: %w", err)
    }

    log.Infof("found %d events to replay", len(events))

    limiter := rate.NewLimiter(rate.Limit(opts.RatePerSecond), 1)
    var succeeded, failed, skipped int

    for i, event := range events {
        // Can be cancelled at any time via context
        if err := limiter.Wait(ctx); err != nil {
            log.Infof("replay cancelled at event %d/%d", i, len(events))
            return err
        }

        if opts.DryRun {
            log.Infof("[DRY RUN] would replay event %s", event.ID)
            skipped++
            continue
        }

        if err := r.processor.Process(ctx, event); err != nil {
            log.Errorf("replay failed for event %s: %v", event.ID, err)
            failed++
            continue
        }
        succeeded++

        // Progress log every 100 events
        if succeeded%100 == 0 {
            log.Infof("replay progress: %d/%d (success=%d fail=%d skip=%d)",
                i+1, len(events), succeeded, failed, skipped)
        }
    }

    log.Infof("replay complete: total=%d succeeded=%d failed=%d skipped=%d",
        len(events), succeeded, failed, skipped)
    return nil
}

5. Event Sourcing Replay #

In an event sourcing architecture, the event log is the source of truth — application state is reconstructed from the sequence of events from the beginning. Replay here means rebuilding a projection or read model from scratch.

// ANTI-PATTERN: rebuilding directly in the production table without a backup
func rebuildProjection() {
    db.Exec("DELETE FROM order_summary") // dangerous! no rollback if it fails
    for _, event := range eventLog.GetAll() {
        applyEvent(event)
    }
}

// CORRECT: rebuild into a shadow table, swap atomically when done
func (p *ProjectionRebuilder) RebuildOrderProjection(ctx context.Context) error {
    shadowTable := fmt.Sprintf("order_summary_rebuild_%d", time.Now().Unix())

    // Create the shadow table first
    if err := p.db.CreateTableLike(shadowTable, "order_summary"); err != nil {
        return fmt.Errorf("create shadow table: %w", err)
    }

    // Stream all events from the start into the shadow table
    events, err := p.eventLog.StreamAll(ctx, "orders", StreamOptions{
        FromOffset: 0,
        BatchSize:  500,
    })
    if err != nil {
        p.db.DropTable(shadowTable) // cleanup
        return fmt.Errorf("stream events: %w", err)
    }

    var count int
    for event := range events {
        if err := p.applyEventToTable(ctx, shadowTable, event); err != nil {
            p.db.DropTable(shadowTable)
            return fmt.Errorf("apply event %d: %w", event.Sequence, err)
        }
        count++
        if count%1000 == 0 {
            log.Infof("rebuilt %d events...", count)
        }
    }

    // Atomic swap: rename shadow → production
    if err := p.db.RenameTable("order_summary", "order_summary_old"); err != nil {
        return err
    }
    if err := p.db.RenameTable(shadowTable, "order_summary"); err != nil {
        return err
    }
    p.db.DropTable("order_summary_old")

    log.Infof("projection rebuild complete: %d events applied", count)
    return nil
}

Main Challenges in Replay #

These three challenges must be anticipated when designing a replay strategy — ignoring them is a source of bugs that are hard to track down.

Duplicate Processing #

Replay almost always produces duplication — the same event gets processed more than once. This isn’t a bug in the replay itself, but a consequence that must be handled with idempotency.

// ANTI-PATTERN: non-idempotent consumer — replay is a disaster
func (c *Consumer) handlePayment(event PaymentRequestedEvent) error {
    // If this event is replayed, the payment is processed twice → double charge
    return c.paymentGateway.Charge(event.UserID, event.Amount)
}

// CORRECT: idempotent consumer — check event_id before processing
func (c *Consumer) handlePayment(ctx context.Context, event PaymentRequestedEvent) error {
    processed, err := c.processedEvents.Exists(ctx, event.EventID)
    if err != nil {
        return fmt.Errorf("check processed: %w", err)
    }
    if processed {
        log.Infof("event %s already processed, skipping", event.EventID)
        return nil // safe to skip during replay
    }

    // Process in a transaction: charge + mark as processed — atomic
    return c.db.Transaction(func(tx *gorm.DB) error {
        if err := c.paymentGateway.Charge(event.UserID, event.Amount); err != nil {
            return err
        }
        return tx.Create(&ProcessedEvent{
            EventID:     event.EventID,
            ProcessedAt: time.Now(),
        }).Error
    })
}
flowchart TD
    A[Event Arrives for Processing] --> B{"event_id exists\nin processed_events?"}
    B -- Yes --> C["Skip — return nil ✓\nReplay is safe"]
    B -- No --> D["Process the Event\nin a DB Transaction"]
    D --> E[Execute business operation]
    E --> F["Insert event_id\ninto processed_events"]
    F --> G{"Transaction\nCommitted?"}
    G -- Yes --> H[ACK to the queue ✓]
    G -- No --> I["Rollback\nEvent will be redelivered"]
    I --> A

Event Ordering #

Replay can change the processing order of events. This is critical for events that depend on each other — for example OrderCreated must always be processed before OrderShipped.

// ANTI-PATTERN: publishing without a partition key — order isn't guaranteed across partitions
func (p *Producer) publishOrderEvent(event OrderEvent) error {
    msg := &sarama.ProducerMessage{
        Topic: "order-events",
        // Without a key: Kafka distributes to partitions round-robin
        // OrderCreated (partition 0) could be processed after OrderShipped (partition 1)
        Value: sarama.ByteEncoder(mustMarshal(event)),
    }
    _, _, err := p.client.SendMessage(msg)
    return err
}

// CORRECT: use the entity ID as the partition key
// All events for the same order are guaranteed to land in the same partition
func (p *Producer) publishOrderEvent(event OrderEvent) error {
    msg := &sarama.ProducerMessage{
        Topic: "order-events",
        Key:   sarama.StringEncoder(event.OrderID), // ← the ordering key
        Value: sarama.ByteEncoder(mustMarshal(event)),
    }
    _, _, err := p.client.SendMessage(msg)
    return err
}

Side Effects That Can’t Be Undone #

This is the trickiest challenge: some side effects aren’t naturally idempotent — sending emails, debiting balances, calling partner webhooks. A poorly designed replay will trigger all of these repeatedly.

// ANTI-PATTERN: side effects directly in the handler without a state check
func (h *OrderHandler) handleOrderConfirmed(event OrderConfirmedEvent) error {
    h.updateDatabase(event)
    h.emailService.SendConfirmation(event.UserEmail) // resent on every replay!
    h.webhookService.Notify(event)                   // called again on every replay!
    return nil
}

// CORRECT: check state first — side effects only fire if the state hasn't changed
func (h *OrderHandler) handleOrderConfirmed(ctx context.Context, event OrderConfirmedEvent) error {
    order, err := h.orderRepo.FindByID(event.OrderID)
    if err != nil {
        return err
    }

    if order.Status == "CONFIRMED" {
        // State is already correct — all side effects already ran before
        // Replay is safe: no duplicate emails, no duplicate webhooks
        return nil
    }

    // Update the state first
    if err := h.orderRepo.UpdateStatus(event.OrderID, "CONFIRMED"); err != nil {
        return err
    }

    // Side effects only execute once because the state has already changed
    h.emailService.SendConfirmation(event.UserEmail)
    h.webhookService.Notify(event)
    return nil
}

Implementation Best Practices #

Store Events Durably #

Replay is only possible if the events still exist. Many systems don’t store events properly and only realize it during an incident.

Minimum requirements for an event store that supports replay:

RequirementReason
Persistent storage, not just in-memoryEvents survive restarts
Events aren’t deleted after processingReplay needs the original events
Full metadata: ID, timestamp, schema version, sourceDebugging and filtering during replay
Queryable by timestamp/ID/business criteriaSelective replay, not necessarily all events
Clear retention policyStorage doesn’t grow without bound

Throttle Mass Replays #

A mass replay without control is the fastest way to take down your own production system.

// ANTI-PATTERN: replaying the entire DLQ at once — flooding the system
func replayAll() {
    events := dlq.GetAll() // could be millions of events
    for _, e := range events {
        process(e) // consumes all downstream resources at once
    }
}

// CORRECT: throttle + dry run + kill switch via context
type ReplayConfig struct {
    RatePerSecond float64       // how many events per second to process
    BatchSize     int           // how many events per batch
    DryRun        bool          // simulation without real execution
    From          time.Time     // filter from a certain time
    To            time.Time     // filter up to a certain time
    Filter        string        // filter by event type or other criteria
}

func (r *ReplayService) ReplayWithControl(ctx context.Context, cfg ReplayConfig) error {
    if cfg.DryRun {
        log.Info("[DRY RUN] no events will actually be processed")
    }
    limiter := rate.NewLimiter(rate.Limit(cfg.RatePerSecond), cfg.BatchSize)

    for _, event := range r.fetchEvents(cfg) {
        // Kill switch: ctx.Cancel() from outside stops the replay at any time
        if err := limiter.Wait(ctx); err != nil {
            log.Info("replay stopped by cancellation")
            return err
        }
        if !cfg.DryRun {
            r.processor.Process(ctx, event)
        }
    }
    return nil
}

Observability Is Mandatory #

Replay without visibility is a blind operation. You don’t know if it succeeded, how much has been processed, or if something failed again.

// CORRECT: informative logs and metrics for every replay operation
type ReplayMetrics struct {
    TotalEvents  int
    Succeeded    int
    Failed       int
    Skipped      int           // idempotency skip
    Duration     time.Duration
    TriggeredBy  string        // who triggered it: "alerting-system", "engineer-unis"
    ReplayReason string        // why: "bug-fix-deploy-v2.3.1", "db-migration"
}

func (r *ReplayService) logCompletion(m ReplayMetrics) {
    log.Infof("[REPLAY COMPLETE] reason=%q triggered_by=%q "+
        "total=%d succeeded=%d failed=%d skipped=%d duration=%v",
        m.ReplayReason, m.TriggeredBy,
        m.TotalEvents, m.Succeeded, m.Failed, m.Skipped, m.Duration)

    // Send to the metrics system for dashboards and alerting
    metrics.Gauge("replay.success_rate",
        float64(m.Succeeded)/float64(m.TotalEvents)*100)
    metrics.Histogram("replay.duration_seconds", m.Duration.Seconds())

    // Alert if the failure rate is too high
    failureRate := float64(m.Failed) / float64(m.TotalEvents)
    if failureRate > 0.05 { // > 5% failures
        alerting.Send(fmt.Sprintf("High replay failure rate: %.1f%%", failureRate*100))
    }
}

Which Replay Type to Use When #

flowchart TD
    A[Need Replay] --> B{"Short transient\nfailure?"}
    B -- Yes, < 1 second --> C["Immediate Retry\nmax 3x"]
    B -- No --> D{"Downstream needs\ntime to recover?"}
    D -- Yes --> E["Exponential Backoff\nwith jitter"]
    E --> F{"Max retries\nexceeded?"}
    F -- Yes --> G[Dead Letter Queue]
    D -- No --> H{"Bug already\nfixed?"}
    H -- Yes, need to\nreprocess historical events --> I["Manual Replay\nfrom Event Store"]
    H -- Need to rebuild\nall state --> J["Event Sourcing\nReplay"]
    G --> K[Alert + Engineer Review]
    K --> I
SituationRight Replay
Transient failure, recovers in millisecondsImmediate retry (≤ 3x)
Downstream needs recovery time (overload, deployment)Exponential backoff + jitter
Event keeps failing after max retriesDead Letter Queue
Bug fix deployed, need to reprocess historical eventsManual replay from the event store
Schema migration, rebuilding a read model from scratchEvent Sourcing replay
Rate limit from a third-party APIDelayed retry with backoff
Major incident, thousands of events in the DLQThrottled batch replay with monitoring

Anti-Patterns to Avoid #

// ✗ Replay without idempotency — produces double processing
for _, e := range dlqEvents {
    processPayment(e) // no check whether it was already processed!
}
// ✓ Always check event_id before reprocessing

// ✗ DLQ without metadata — hard to debug during investigation
dlq.Send(Message{Body: originalMsg.Body})
// ✓ Include the failure reason, attempt count, and full timestamp
dlq.Send(DLQMessage{
    Body:          originalMsg.Body,
    FailureReason: err.Error(),
    AttemptCount:  originalMsg.ReceiveCount,
    FailedAt:      time.Now(),
    StackTrace:    debug.Stack(),
})

// ✗ Mass replay without a kill switch — can't stop it in an emergency
func replayAllDLQ() {
    for _, e := range getAllEvents() {
        process(e) // no way to stop if the system starts struggling
    }
}
// ✓ Always use context as the kill switch
func replayAllDLQ(ctx context.Context) {
    for _, e := range getAllEvents() {
        if ctx.Err() != nil {
            log.Info("replay cancelled")
            return
        }
        process(ctx, e)
    }
}

// ✗ Events stored only in an in-memory queue — lost on restart
queue := make(chan Event, 1000) // not persistent
// ✓ A durable event store: PostgreSQL, S3, or Kafka with a long retention

// ✗ Side effects without a guard — email sent on every replay
func handleOrderConfirmed(event Event) {
    emailService.Send(event.UserEmail) // sent every time it's called
}
// ✓ Check state first, side effects only if the state hasn't changed

Replay Strategy Implementation Checklist #

EVENT STORE DESIGN:
  □ Events stored in persistent storage with a clear retention policy
  □ Every event has a unique ID, timestamp, schema version, and source
  □ Events queryable by time, ID, type, or business criteria
  □ Events aren't deleted after processing — only marked

CONSUMER IDEMPOTENCY:
  □ All consumers check event_id before processing
  □ Business operation and event_id recording in a single DB transaction
  □ Consumers still return success if the event was already processed (skip)

DEAD LETTER QUEUE:
  □ DLQ configured for all production queues
  □ Every event in the DLQ stores: failure reason, attempt count, timestamp
  □ Alerts attached when the DLQ receives a new event
  □ DLQ review process documented in the runbook

MANUAL REPLAY:
  □ Replay tooling available and documented
  □ Replay supports dry run mode
  □ Replay has a rate limiter and can be cancelled (context cancel)
  □ Replay progress and results fully logged

SIDE EFFECTS:
  □ Email, webhook, and charge operations protected with state checks
  □ Side effects only execute if the state hasn't changed
  □ Replay tested in staging before production

OBSERVABILITY:
  □ Every replay operation has logs: triggered_by, reason, result
  □ Metrics: success rate, failure count, duration
  □ Alert if the replay failure rate exceeds the threshold

Summary #

  • A Replay Strategy is an architectural decision — not just a retry loop; it covers how to store events, preserve ordering, handle duplication, and control the replay process in production.
  • Failure is a certainty — design replay from the start, don’t wait for the first incident.
  • Five types of replay — immediate retry (short transient), exponential backoff (needs recovery time), DLQ (permanent failure), manual replay (bug fix/audit), event sourcing replay (state rebuild).
  • Idempotency is a hard requirement for replay — without idempotency, replay is a recipe for double processing and corrupted data.
  • A DLQ is a mandatory safety net — events must never be lost just because they can’t be processed right now; include full metadata for debugging.
  • Exponential backoff + jitter prevents retry storms — don’t retry immediately when the downstream needs recovery time; jitter prevents thundering herds.
  • Throttle mass replays — replaying thousands of events without rate limiting can take down production; always provide a kill switch via context.
  • Side effects need guards — check state before sending emails, webhooks, or charges; side effects should only happen once.
  • Observability is mandatory — log triggered_by, reason, success/failure counts, and duration for every replay operation.

← Previous: Race Condition   Next: Reactive Programming →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact