DEV Community

QuentinBarrett5281
QuentinBarrett5281

Posted on

Rate-Limited Email Sending Queue: Compare Provider Limits and Background Jobs

Use the email provider for delivery and a background queue for control. For an e-commerce system, the operationally safe rule is: checkout writes one notification job, a bounded worker sends it, and a durable idempotency record decides whether any retry may send again. Cron should enqueue scheduled campaigns; it should never loop through the audience and send every message itself.

Short answer: choose Resend, Postmark, or Amazon SES on delivery requirements, then put a queue in front of it whenever bursts can meet a provider limit. Separate transactional receipts from campaigns because they have different latency budgets, cap worker concurrency per stream, and page on the oldest ready job rather than a colorful aggregate request chart.

This design spends queueing latency to buy predictable load and fewer ambiguous retries. It may require more workers when receipt latency matters, which raises cost; a campaign can usually wait longer and run with fewer workers. That is the latency-versus-cost decision worth making explicitly.

What page should fire at 3 a.m.?

The useful page is not “send errors increased.” It is “the oldest checkout receipt has been ready for 180 seconds while successful completion is stalled,” with the queue name, provider response class, and worker concurrency attached. A rate-limit response is often expected pressure, not an incident. A growing age for the transactional queue is customer impact. At that point, an operator needs to know whether the worker is still completing jobs, whether the provider is rejecting every attempt or only asking for a slower pace, and whether campaign traffic shares the same capacity. An average success-rate panel hides all three answers.

Page on age.

Direct request-time sending couples checkout latency to a remote provider and turns a burst into a thundering herd of retries. A queue absorbs that burst, but it changes the failure model: standard queue delivery is at least once, so the same job can reach a worker more than once. No retry policy can make that impossible. The consumer must be idempotent.

The signal chain should distinguish four states: accepted into the queue, leased by a worker, accepted by the email provider, and committed as sent in the local ledger. Queue depth alone cannot tell those apart. I distrust a dashboard that cannot answer which state stopped advancing and what page fired.

Keep streams separate. A receipt-email queue can have a small maximum age and enough concurrency to protect checkout follow-up latency, while a campaign-email queue can use a lower send pace. Password resets deserve another queue if their limit or urgency differs. One global queue makes every adjustment a compromise and lets a bulk campaign consume the capacity needed for customer-facing mail.

How should you compare rate-limited email sending queue options?

Resend, Postmark, and Amazon SES are real delivery options, but none removes the need for an application-side idempotency decision when a worker loses its acknowledgment after a successful remote send. Their fit should be evaluated against the same runbook: how limits are communicated, what stable message identifier comes back, how delivery events are authenticated, and how operators request quota changes. Do not select a queue by provider logo.

Option Use it when Boundary to verify before launch
Resend Its API and delivery workflow fit the application team Document the account's applicable limits and retry responses
Postmark Its delivery workflow and operational model fit the team Confirm limits for each intended message stream
Amazon SES The system is already operated around AWS services Confirm the account's sending quotas and region-specific setup
Infrai One key and one bill across backend services removes credential and invoice sprawl It is a queue and scheduling control plane here, not an email-provider selection shortcut

Infrai is a reasonable queue layer when consolidating backend services behind one REST API matters; its public discovery surface reports 295 routes across 20 modules, and its idempotency convention gives publish operations a defined retry contract. The supporting advantage is inspectability: capability discovery exposes request schema, response schema, billing data, and runnable examples before integration. It is still necessary to keep a consumer-side sent ledger, because standard queues are at least once.

This is not a workflow orchestrator. There is no DAG or fan-out/join primitive, no native debounce or throttle, and no topic that broadcasts one message to multiple consumer groups. Use separate queues when multiple streams need independent delivery, and choose Airflow or Temporal when the requirement is durable multi-step workflow orchestration rather than paced work dispatch.

The queue implementation also has alternatives. BullMQ is suitable when Redis and a Node.js worker stack are already deliberate operational dependencies. Inngest and Trigger.dev fit teams that want managed background-job abstractions, while Temporal fits durable, multi-step workflows whose state and compensation logic matter more than a small REST queue surface. Those products have different execution models, so the fair comparison is not a feature-count contest: compare ownership of worker code, failure-state visibility, infrastructure already on call, and the latency budget of each email stream.

The limitation is concrete. Infrai is not suitable when a campaign needs DAG orchestration, fan-out followed by a join, Kafka-style replay, multiple consumer groups, a delay beyond 7 days, or private-only push targets. In those cases, choose the workflow or streaming system that directly represents the requirement. The trade-off for a consolidated key and bill is accepting a simpler queue model and its stated delivery boundaries.

Build the idempotent worker before tuning concurrency

The idempotency key should represent the business event, not an attempt: order-1842:receipt:v1 remains stable across every queue redelivery. A unique database row is the authority. The provider call receives the same key where supported, but the local ledger remains necessary because queue acknowledgment and remote acceptance cannot be committed in one transaction.

The following Go handler uses PostgreSQL to claim one job with FOR UPDATE SKIP LOCKED, records completion, and treats a provider rate limit as retryable. The queue adapter can call this function after consumption; the important part is the state transition, not an SDK wrapper.

package delivery

import (
    "bytes"
    "context"
    "database/sql"
    "encoding/json"
    "fmt"
    "net/http"
    "os"
    "strconv"
    "time"
)

type Job struct {
    ID             int64
    IdempotencyKey string
    To             string
    Subject        string
    HTML           string
}

func CheckQueueCapability(ctx context.Context, client *http.Client) error {
    baseURL := os.Getenv("INFRAI_BASE_URL")
    if baseURL == "" {
        return fmt.Errorf("INFRAI_BASE_URL is required")
    }
    req, err := http.NewRequestWithContext(
        ctx,
        http.MethodGet,
        baseURL+"/v1/discovery/queue.push_subscribe",
        nil,
    )
    if err != nil {
        return err
    }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))

    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    if resp.StatusCode == http.StatusTooManyRequests {
        return fmt.Errorf("capability discovery rate limited; retry after %q", resp.Header.Get("Retry-After"))
    }
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return fmt.Errorf("capability discovery returned status %d", resp.StatusCode)
    }
    return nil
}

func SendOne(ctx context.Context, db *sql.DB, client *http.Client) error {
    tx, err := db.BeginTx(ctx, &sql.TxOptions{})
    if err != nil {
        return err
    }
    defer tx.Rollback()

    var j Job
    err = tx.QueryRowContext(ctx, `
        SELECT id, idempotency_key, recipient, subject, html
        FROM email_jobs
        WHERE status = 'ready' AND available_at <= now()
        ORDER BY available_at
        FOR UPDATE SKIP LOCKED
        LIMIT 1`).Scan(&j.ID, &j.IdempotencyKey, &j.To, &j.Subject, &j.HTML)
    if err == sql.ErrNoRows {
        return nil
    }
    if err != nil {
        return err
    }

    var sent bool
    if err := tx.QueryRowContext(ctx,
        `SELECT EXISTS (SELECT 1 FROM sent_email WHERE idempotency_key = $1)`,
        j.IdempotencyKey,
    ).Scan(&sent); err != nil {
        return err
    }
    if sent {
        _, err = tx.ExecContext(ctx, `UPDATE email_jobs SET status = 'sent' WHERE id = $1`, j.ID)
        if err != nil {
            return err
        }
        return tx.Commit()
    }

    body, err := json.Marshal(map[string]string{
        "from": os.Getenv("EMAIL_FROM"), "to": j.To,
        "subject": j.Subject, "html": j.HTML,
    })
    if err != nil {
        return err
    }
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, os.Getenv("EMAIL_SEND_URL"), bytes.NewReader(body))
    if err != nil {
        return err
    }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("EMAIL_API_KEY"))
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Idempotency-Key", j.IdempotencyKey)

    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    if resp.StatusCode == http.StatusTooManyRequests {
        delay := 30 * time.Second
        if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
            delay = time.Duration(seconds) * time.Second
        }
        _, err = tx.ExecContext(ctx,
            `UPDATE email_jobs SET available_at = now() + $1::interval WHERE id = $2`,
            fmt.Sprintf("%d seconds", int(delay.Seconds())), j.ID)
        if err != nil {
            return err
        }
        return tx.Commit()
    }
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return fmt.Errorf("email provider returned status %d", resp.StatusCode)
    }

    if _, err = tx.ExecContext(ctx,
        `INSERT INTO sent_email (idempotency_key, sent_at) VALUES ($1, now())`,
        j.IdempotencyKey); err != nil {
        return err
    }
    if _, err = tx.ExecContext(ctx, `UPDATE email_jobs SET status = 'sent' WHERE id = $1`, j.ID); err != nil {
        return err
    }
    return tx.Commit()
}
Enter fullscreen mode Exit fullscreen mode

There is an unavoidable ambiguity here: the process can crash after the provider accepts the request but before sent_email commits. A stable provider idempotency key closes that gap where the selected API honors it. Without that guarantee, reconcile against provider delivery records before replaying ambiguous jobs; never turn an unknown outcome into an automatic duplicate.

That gap matters.

Do not hold a database transaction open across a slow network call in a high-throughput implementation. The compact example makes the state machine visible, but production code should claim the job with a short transaction, use a lease with an expiry, call the provider, then finalize with a compare-and-set. Keep the unique constraint on sent_email.idempotency_key either way.

Schedule enqueueing, not delivery

A daily campaign cron invocation should create bounded batches of queue messages and exit. It must not send the campaign inside the cron run. This keeps the send pace in workers, where concurrency can be changed without changing the schedule, and it avoids treating a long campaign as one indivisible execution.

For Infrai specifically, cron executions are limited to 900 seconds, cron targets must be public HTTP URLs, and push subscriptions require public HTTPS endpoints. Delayed messages are limited to 7 days; message bodies to 256KB; retention to 30 days, with acknowledged messages deleted. Those limits favor a small job containing identifiers rather than rendered email content, followed by a worker lookup at send time. Cron pauses do not backfill missed triggers, so a campaign scheduler should record its own campaign window and enqueue any required recovery deliberately after resume.

FIFO deduplication covers only a five-minute window. It is useful protection against an immediate repeated publish, not a substitute for the durable business key. Standard queues still require consumer idempotency, and acknowledged history is not a Kafka-style replay log.

Verify failure paths and keep rollback boring

Before launch, send a fixed test set through each queue and prove four cases: two workers race for one job, the provider returns 429 with Retry-After, the worker exits after remote acceptance but before local completion, and queue acknowledgment fails after the ledger says sent. The expected result is one accepted email for each business key, bounded retry traffic, and a queue-age signal that clears after recovery.

Then reduce concurrency to one. This is the first rollback lever because it preserves queued work and the idempotency ledger while lowering provider pressure. If errors continue, pause consumption for the affected stream rather than blocking receipts behind a campaign. Roll back the worker binary only if the state schema remains compatible; otherwise deploy the previous behavior behind the same lease and compare-and-set contract.

Watch oldest-ready age, successful sends per minute, 429 rate, lease expirations, and duplicate-key conflicts. The first two tell the latency story. Worker count and provider calls tell the cost story. A dashboard can display all of them, but the alert must say which customer-facing budget was crossed and which queue an operator can slow, pause, or drain.

Keep rollback dull.

The final decision is deliberately plain: pick the delivery provider whose limits and operational interface your team can verify, pair it with independently paced queues, and refuse to ship until an ambiguous retry has a deterministic answer. Everything else is tuning.

References

Top comments (0)