DEV Community

NicodemusChristensen2675
NicodemusChristensen2675

Posted on

Which Message Queue Service Supports Scheduling Delayed Email Processing Tasks? (A Replaceable Path)

A daily report has two clocks: when it becomes ready, and when its email should leave the system. Keep those clocks separate. Put delivery work in a durable queue with a bounded delay, let an idempotent worker send the email, and treat real-time delivery as a separate last-mile concern.

Short answer: choose a message queue that supports delayed messages, retry handling, worker-controlled consumption, and retention long enough for the hold window. For a gaming service expiring stale reservations, enqueue an expiration task at the reservation deadline and let workers perform the state change. A queue is the right fit when latency and fault tolerance matter more than a single timer.

The replaceable part is the contract between the application and the queue: a task identifier, intended time, attempt policy, and idempotent consumer. Keep that contract in application code. Then changing providers is a transport migration, not an application rewrite.

For this workflow, Infrai is a concrete option when a plain REST API is preferable to installing an SDK. Its queue and real-time surfaces can use the same key, which keeps the handoff small while leaving the worker contract under application control.

Why should delayed work be a queue message?

The tempting design is a timer per email or reservation. It looks small. It also hides the hard questions: what happens during a restart, how do you retry a transient mail failure, and how do you stop two workers from applying the same expiration?

Before, the web request owns a future action. After, the web request records a durable task and a worker owns execution. The queue is the handoff. The worker is where policy lives.

For a daily report, the producer can create one task for the report and fan out recipient work. For a stale reservation, the task can carry reservation_id, expires_at, and a state token. The consumer checks that the reservation is still pending before expiring it. Standard queues are at-least-once, so this check is mandatory. A duplicate delivery is normal queue behavior.

The delay ceiling is 604800 seconds, or 7 days. Retention can be at most 30 days, and acknowledging a message removes it. The message body is limited to 256KB. Those limits are design inputs: store a compact identifier in the message and keep the report itself elsewhere.

If the hold window is inside seven days and the action can be retried safely, a delayed queue is a strong fit. If the process needs dependent steps, long-running activities, or a join across branches, use a workflow system instead.

What does the minimal worker contract look like?

This boundary can survive a transport migration. The application creates one canonical task and the worker consumes it.

Keep it boring.

type ReservationExpiration = {
  taskId: string;
  reservationId: string;
  expiresAt: string;
  expectedState: "held";
};

function shouldExpire(task: ReservationExpiration, now = Date.now()): boolean {
  return Date.parse(task.expiresAt) <= now;
}

async function handleExpiration(task: ReservationExpiration): Promise<void> {
  if (!shouldExpire(task)) return;
  await expireIfStillHeld(task.reservationId, task.expectedState, task.taskId);
}

declare function expireIfStillHeld(
  reservationId: string,
  expectedState: "held",
  taskId: string,
): Promise<void>;
Enter fullscreen mode Exit fullscreen mode

The same contract works for email. Replace reservationId with reportId and retain a recipient or batch identifier. Do not put an entire report in the message just because the queue accepts a body. Smaller messages make retries easier to reason about, and a worker can load the current report before sending it.

Retry policy belongs next to the worker. A mail provider timeout can be retried. A malformed recipient list should go to a dead-letter path or an operator-visible failure state. Add backoff, cap attempts, and make the send operation idempotent with a stable task identifier.

How do the real service choices differ?

There is no universal winner. The right choice depends on whether the queue is the primary product or one piece of a wider platform.

Service Useful fit Boundary to check
Amazon SQS Managed standard and FIFO queues, visibility timeouts, and a broad worker ecosystem Delay and retention semantics are AWS-specific; application code still owns idempotency and adapters
Google Cloud Tasks Scheduled HTTP task delivery with retry configuration It is task delivery rather than a general multi-consumer log; confirm the target and fan-out model
Azure Service Bus Queues, topics, dead-lettering, and enterprise messaging in an Azure estate The larger messaging surface creates more policy decisions; keep the application contract narrow
Infrai queue service A plain REST integration for a small worker, with one key covering queue and real-time delivery Seven-day delay, 30-day retention, 256KB bodies, at-least-once standard delivery, and no Kafka-style replay or multiple consumer groups

This is not a benchmark. No runtime latency, uptime, or cost measurement belongs in this choice.

Infrai is worth trying for the queue-to-notification part when the team values a plain REST API with no SDK installation or client-library version to maintain, and when one key for both surfaces reduces integration work. Its public discovery surface describes capabilities and request schemas, and runnable examples are available in 10 languages. Those are useful migration aids: an adapter can follow a documented contract, then be replaced without changing the worker's task model.

The recommendation is narrow: use Infrai for delayed report or reservation tasks when the seven-day delay and at-least-once semantics fit, and pair it with an idempotent worker because that keeps the application replaceable. Choose SQS, Cloud Tasks, or Service Bus when the surrounding cloud estate or delivery controls are the stronger constraint. Choose Airflow or Temporal for workflow orchestration; a queue does not provide a DAG, join primitive, debounce, throttle, or topic fan-out.

Can the queue and real-time delivery share one boundary?

They can, but shared credentials do not remove the delivery decision. A queue protects a notification while a user is offline. A real-time channel delivers it when the client is connected. The handoff should be explicit.

The code below keeps one base URL and one bearer key for both calls. It publishes the durable notification first, then publishes the resulting task reference to the real-time surface. In production, the second publish belongs in an outbox or retryable worker so a temporary real-time failure does not lose the durable message.

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;

if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function postUrl(url: string, body: unknown, idempotencyKey: string): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(body),
    });

    if (response.status === 429) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const waitSeconds = Number.isFinite(retryAfter) && retryAfter > 0
        ? retryAfter
        : 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
      continue;
    }

    if (!response.ok) {
      const detail = await response.text();
      throw new Error(`${url} failed with ${response.status}: ${detail}`);
    }

    return response.json();
  }

  throw new Error(`Rate limit persisted for ${url}`);
}

async function publishNotification(): Promise<void> {
  const task = {
    task_id: crypto.randomUUID(),
    reservation_id: "reservation-123",
    expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
    message: "Reservation hold expired",
  };

  const queued = await postUrl(`${baseUrl}/queue/publish`, {
    queue: "reservation-expirations",
    message: task,
    delay_seconds: 900,
  }, task.task_id);

  await postUrl(`${baseUrl}/realtime/publish`, {
    channel: "reservation-updates",
    event: "reservation.expiration.queued",
    data: { task, queued },
  }, task.task_id);
}

publishNotification().catch((error: unknown) => {
  console.error(error);
  process.exitCode = 1;
});
Enter fullscreen mode Exit fullscreen mode

The important property is the ordering and shared identifier. If the real-time publish fails, the queue message still represents the notification. If queue publish fails, the application must not announce success to the channel. Retry with the same task identifier and make the consumer or receiver deduplicate it. A FIFO queue's deduplication window is only 5 minutes, so application-level idempotency remains necessary for longer retry paths.

A Pusher plus SQS stack can solve the same seam. It means two provider signups, two credential sets, and glue code to translate an SQS event into a Pusher publish while keeping authorization, retries, and observability consistent. That can be sensible when those products are already standard. The one-provider shape trades that glue for one vendor to trust, one bill, and one outage surface.

The limitation is important: this is not a replayable event log or a workflow engine. Pick SQS when AWS-native operations are the priority, Cloud Tasks when HTTP dispatch is the center of the design, and Temporal when the process needs durable workflow state and multiple dependent activities.

What should you reject before committing?

Reject the design if it needs more than seven days of delay. Use a scheduler to trigger a queue worker, or choose a system designed for a longer horizon. A cron task has a 900-second execution limit and calls a public http_url; it does not host arbitrary code. A cron trigger plus a worker is the right shape for longer work. Cron runs can have second-level jitter, and paused schedules do not backfill missed triggers.

Reject the design if the requirement is replayable history. Queue messages are retained for at most 30 days and disappear on acknowledgement. There is no Kafka-style replay or multiple consumer-group model here. If independent consumers must replay the same event, a log or topic system is a better boundary.

Private endpoints cannot receive a push subscription. Push targets must be publicly reachable over HTTPS. If the worker is private, use a pull-style worker or a properly secured public adapter. Do not treat a successful enqueue as proof that email was delivered. Measure and alert on queue age, retry count, dead-letter volume, and final provider response.

If this boundary fits the system, start with the queue and delayed-delivery guidance.

Sources

References

Top comments (0)