For failed e-commerce reservation tasks, the choice between a Redis retry queue and AWS SQS is governed by the hold window. If a reservation must expire after 15 minutes, the retry mechanism cannot quietly turn delayed cleanup into an available-stock bug.
Short answer: use a durable queue for failed cleanup work, and treat Redis as a fast coordination layer unless you can explicitly accept its durability and recovery boundaries. The important design is not the queue brand. It is an idempotent expiration command, a deadline carried with every message, and a measured choice between lower latency and higher operational cost.
Should a failed reservation use a Redis retry queue or AWS SQS?
A reservation is a claim on inventory, not a payment. At checkout, the service records a reservation ID, the SKU, quantity, and an absolute expiration time. A worker later marks the reservation expired if it is still open. That last condition matters. A late cleanup must not cancel an order that completed during a retry delay.
The minimum state transition is something like this:
type Reservation = {
id: string;
sku: string;
quantity: number;
expiresAtMs: number;
status: "held" | "confirmed" | "expired";
};
function mayExpire(reservation: Reservation, nowMs: number): boolean {
return reservation.status === "held" && nowMs >= reservation.expiresAtMs;
}
The queue does not own the truth. The reservation record does. A message can arrive twice, arrive late, or arrive after a worker restart. The database update should therefore be conditional on status = held and the stored deadline having passed. The operation should be safe to repeat.
This is where many retry-queue comparisons go wrong. They compare enqueue and dequeue latency while ignoring the stale-message path. The stale-message path is the product behavior.
Why does the fixed window change the queue choice?
A fixed hold window gives us a decision rule: measure the worst acceptable delay from the deadline to the successful expiration, then compare that with the recovery and operating burden of each queue design. Do not use average latency. A reservation that expires in 20 ms on average but takes 40 seconds during a worker restart still creates a visible inventory problem.
For a 15-minute hold, I would start with a 10-second cleanup target after the deadline and instrument the tail, not just the mean. That target is an engineering policy, not a universal number. A flash-sale allocation may need a tighter bound; a made-to-order item may tolerate a wider one. The useful measurement is expiration_lag_ms, split by normal operation, consumer restart, and dependency failure.
Redis can make the ready-to-work path very quick and can support sorted-set based due-time selection. It also adds a scheduling loop, claiming rules, visibility recovery, persistence settings, failover behavior, and an answer for what happens to work that was in flight when a process died. Those are not free abstractions. The limitation is clear: Redis is not suitable when the team has no tested answer for persistence loss, failover, or replay.
A managed queue such as Amazon SQS shifts much of the queue availability and redelivery machinery outside the application. Its delivery model still requires idempotency: standard queues provide at-least-once delivery, and visibility timeout is a lease rather than a completion guarantee. A message can become visible again when processing takes too long or a worker fails. The trade is usually less queue machinery in the code and more attention to service limits, network distance, request latency, and ongoing usage costs. AWS SQS is a poor fit when the deadline depends on sub-second local coordination or when the application cannot tolerate a network dependency in the expiration path.
RabbitMQ with a dead-letter exchange is another useful reference point. Its dead-lettering model can route rejected or expired messages, but it does not decide whether the reservation is safe to expire. That decision remains in the application state transition. Queue features do not remove domain invariants. Redis is a poor fit when the team cannot operate persistence, claim recovery, and replay; its lower path latency does not compensate for missing recovery rules.
The smallest working implementation
Keep the payload boring. Include the reservation identifier and the deadline. Do not serialize an entire cart or assume that the message's arrival time is the expiration time.
type ExpireMessage = {
reservationId: string;
expiresAtMs: number;
attempt: number;
};
type ReservationStore = {
expireIfDue: (input: {
reservationId: string;
expectedExpiresAtMs: number;
nowMs: number;
}) => Promise<"expired" | "already_done" | "not_due" | "missing">;
};
type RetryQueue = {
publish: (message: ExpireMessage) => Promise<void>;
};
async function handleExpiration(
message: ExpireMessage,
store: ReservationStore,
queue: RetryQueue,
nowMs: number,
): Promise<void> {
if (nowMs < message.expiresAtMs) {
await queue.publish(message);
return;
}
const result = await store.expireIfDue({
reservationId: message.reservationId,
expectedExpiresAtMs: message.expiresAtMs,
nowMs,
});
if (result === "not_due") {
await queue.publish({ ...message, attempt: message.attempt + 1 });
}
}
The example intentionally leaves the queue adapter out. That is the seam worth testing. A Redis adapter might poll due items from a sorted set. An SQS adapter might use a delayed message or a scheduled producer. A RabbitMQ adapter might use a dead-letter topology. The handler still needs the same state check and the same failure policy.
There is a subtle trap in the early nowMs < expiresAtMs branch. Re-publishing immediately can create a hot loop if clocks, delay granularity, or consumer timing are wrong. In a real adapter, compute a bounded delay, record the reason, and avoid retrying faster than the system can make progress. Also use one clock convention. Store UTC epoch milliseconds, and monitor clock skew between producers and consumers.
What should we measure before choosing?
I would run a small failure test with real message sizes and the same worker concurrency planned for production. The dataset does not need to be huge. Five thousand reservations, a 15-minute hold, a 10-second target, and a controlled worker restart are enough to expose several bad assumptions. Repeat it under dependency timeouts and duplicate deliveries.
Track these measurements:
- expiration lag at p50, p95, and p99
- duplicate handler executions per reservation
- messages delayed past their deadline
- recovery time after a worker and queue restart
- retry attempts by failure reason
- operator actions required to drain or replay work
Do not call a queue cheaper because its monthly bill is smaller. Include the worker polling, persistence, replication, monitoring, network calls, incident time, and replay tooling. The cost axis is a system cost. The latency axis is also a system latency: queue delay plus database contention plus downstream API time.
The failure test should include a confirmed order racing an expiration message. The correct result is one terminal state, not whichever worker writes last. If the schema cannot enforce that, changing queues is premature.
What I would change at scale
At higher volume, I would separate scheduling from expiration execution. A due-time index can find candidates, while a durable retry queue carries failed executions. That prevents a slow downstream dependency from blocking the entire scheduler. I would also keep a compact retry record with the first failure time, next attempt time, and terminal reason.
For a Redis-centered design, the hard questions are persistence during failure, ownership of claimed items, and replay after a restart. For an SQS-centered design, the hard questions are visibility timeout sizing, duplicate delivery, delay limits, and how the application exposes poison messages for inspection. For RabbitMQ, ask how dead-letter routing, acknowledgements, and queue recovery fit the team's operational model. None of these is a reliability score. They are boundaries to test.
The practical decision is narrow. If the deadline requires very low scheduling latency and the team already operates the required recovery machinery, a Redis-based scheduler can fit. If removing queue availability work matters more than shaving a small amount of latency, a managed durable queue can fit. If message routing and acknowledgement control are central, a broker with dead-lettering may fit. Keep the choice reversible behind the adapter and make the reservation update idempotent first.
A queue can deliver work. It cannot prove that a reservation is stale. The database condition, deadline semantics, duplicate handling, and tail-latency budget do that.
Sources
- Redis sorted sets: https://redis.io/docs/latest/develop/data-types/sorted-sets/
- Redis persistence: https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/
- Amazon SQS visibility timeout: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html
- Amazon SQS standard queue delivery: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/standard-queues-at-least-once-delivery.html
- RabbitMQ dead-letter exchanges: https://www.rabbitmq.com/docs/dlx
- RFC 3339 date and time format: https://www.rfc-editor.org/rfc/rfc3339
Top comments (0)