all posts

The 8 Best AWS SQS Alternatives in 2026

Ajay Kumar··10 min read

Nobody wakes up wanting to replace SQS. What happens is smaller than that. You need a job to run in forty minutes, and DelaySeconds caps at fifteen, so you write a scheduler on top. Then someone asks for a priority lane, so you make three queues and a consumer that polls them in order and starves the third one. Then an incident review asks to replay yesterday's payment events, and there is nothing to replay, because a message you consumed is a message that no longer exists. None of these are SQS failing. They are all SQS being exactly the thing it says it is, and the thing it says it is stopped matching your problem about six months ago.

I'm Ajay, I build PandaStack. We are not a message broker and we do not sell one, so I have no queue to talk you into. What we run is the other half of this picture — the long-lived consumer process that drains whatever queue you pick — which is the piece that gets awkward on serverless. I'll be specific about that when I get to it, and it is one entry among eight, not the punchline.

What SQS actually is, stated accurately

SQS is a fully managed, pull-based queue. There is no broker to size, no disk to fill, no cluster to upgrade at 2am, and no capacity planning beyond your own consumers. That is a genuinely large amount of operational work that simply does not exist for you, and it is why the service has outlasted a decade of fashionable replacements.

  • Standard queues give at-least-once delivery and best-effort ordering, with throughput that is effectively unbounded from your side. Your consumers must be idempotent. This is not a caveat you can skip; duplicates happen in normal operation, not just during failures.
  • FIFO queues give strict ordering within a message group and deduplication inside a window, in exchange for lower throughput ceilings and per-group serialisation. High-throughput mode raises the limits considerably — check the current quotas in the AWS docs rather than a number you remember from 2021.
  • Consumers poll. ReceiveMessage with long polling holds the connection for up to twenty seconds and returns whatever is there. There is no persistent subscription the broker pushes into.
  • A received message is hidden, not removed. The visibility timeout is a lease; you DeleteMessage to acknowledge. Crash before deleting and the message reappears when the lease expires, which is the retry mechanism.
  • A redrive policy moves a message to a dead-letter queue after maxReceiveCount failed receives, which is how poison messages stop eating your consumers.
  • Billing is per request. Every send, every receive, every delete, and every empty long poll that returns nothing is an API call.

Read that list as a design, not a feature table. Every one of those choices buys simplicity and costs something specific, and the four things people leave for map onto them almost one-to-one.

The four reasons people go looking

Cost shape at volume. Per-request pricing is wonderful at low volume and gets attention at high volume, particularly because receives count too. A fleet of consumers long-polling an empty queue is generating billable requests to learn there is no work.

Missing scheduling primitives. Delay caps at fifteen minutes, so anything longer becomes your own scheduler with its own state. There are no priorities, only more queues. There is no per-message deferral to an arbitrary future timestamp.

Topology. SQS is a point-to-point queue. Fan-out means adding SNS or EventBridge in front of it, which is a fine architecture and also a second service, second IAM surface, and second thing to reason about when a message goes missing.

Replay. A consumed message is gone, and retention is bounded regardless. If you want to reprocess a window of history — because you shipped a bug, or you added a new consumer that needs the last week — a queue is structurally the wrong tool. That is a log, and it is the honest reason Kafka keeps showing up in these comparisons.

The eight alternatives

1. RabbitMQ, managed or self-hosted

The closest thing to a superset of SQS's semantics. Exchanges and bindings give you routing and fan-out in the broker rather than in a second AWS service. You get priorities, per-message TTL with dead-letter exchanges as a delay mechanism, push delivery to persistent AMQP consumers with a prefetch window, and quorum queues for replicated durability. Managed options exist from Amazon MQ, CloudAMQP and others, so this is not automatically an ops project.

Worse at: being invisible. A RabbitMQ node has memory watermarks, disk alarms, and a well-known failure mode where an unbounded queue turns into a paging event. Clustering and partition handling are real subjects you will have to learn. It is also not a log, so replay is not on the menu.

2. Redis-backed queues: BullMQ, Sidekiq, RQ, Celery

This is what most application teams actually want, and they discover it after building half of it themselves. These libraries give you delayed and scheduled jobs at arbitrary timestamps, priorities, retries with backoff, unique jobs, rate limiting, repeatable jobs, and a dashboard your on-call can read. Latency is sub-millisecond and the mental model is a data structure, not a protocol.

Worse at: durability guarantees. Redis persistence is configurable and real, but it is not a WAL-backed transactional store, and a failover can lose the tail of your writes. Everything lives in memory, so a backlog is a memory-pressure problem. Treat it as excellent for jobs you can tolerate re-deriving, and think harder before it is the system of record for money.

3. NATS JetStream

A single small Go binary that gives you subject-based pub/sub, persistent streams with replay from a sequence or a timestamp, durable consumers with explicit acks, work queues, and key-value and object stores in the same cluster. Operationally it is the lightest thing on this list that still does persistence properly, and the wildcard subject model makes fan-out topologies genuinely pleasant.

Worse at: ecosystem gravity. Fewer managed offerings, fewer engineers who have run it before, and a smaller library of integrations than Kafka or RabbitMQ. Ack semantics and consumer configuration have enough knobs that a misconfigured AckWait will bite you the same way a bad visibility timeout does.

4. Kafka or Redpanda — a different shape, not a replacement

Be clear about this one. Kafka is a partitioned, replayable log. Consumers track an offset; nothing is removed when it is read; retention is by time or size. That gives you the thing SQS structurally cannot do — reprocess history, add a new consumer group that reads from the beginning, keep ordering per partition key. Redpanda is a Kafka-protocol-compatible broker without ZooKeeper or a JVM, which removes a chunk of the operational tax.

Worse at: being a task queue. Per-message acknowledgement, arbitrary redelivery of one bad record, delays, and priorities are all awkward-to-absent, because a log does not have those concepts. Head-of-line blocking within a partition is a real operational problem when one message is slow. If your workload is 'run this job, retry it if it fails', Kafka is the wrong shape and you will spend a year building a queue on top of it.

5. Google Cloud Pub/Sub

The nearest managed equivalent in another cloud, with a topic-and-subscription model that gives you fan-out natively rather than bolting on a second service. It supports both pull and push subscriptions, ordering keys, exactly-once delivery within a subscription, dead-letter topics, and message retention with seek, so limited replay is available.

Worse at: being outside GCP. If your compute is in AWS, you are paying cross-cloud egress and adding a second cloud's IAM to your incident surface. Pick it because you are on GCP, not because it beats SQS on a feature grid.

6. Cloudflare Queues

Queues that speak natively to Workers, with batched consumption, configurable retries and delays, and dead-letter queues, wired into the same platform as R2 and D1. If your producers are already at the edge, the integration is about as short as this kind of code gets, and there is no region to pick.

Worse at: long or heavy consumers. The consumer is a Worker, so you inherit the Workers runtime — CPU limits, no arbitrary native binaries, no long-running process holding a connection pool. Great for 'transform and forward', constraining for 'run ffmpeg for six minutes'.

7. Postgres as the queue, with SKIP LOCKED

The option that removes a component instead of swapping one. If you already run Postgres, a jobs table plus SELECT ... FOR UPDATE SKIP LOCKED is a correct, durable, transactional queue with no new infrastructure, no new credentials, and no new dashboard. SKIP LOCKED, available since Postgres 9.5, lets concurrent workers claim disjoint rows without blocking each other, which is the entire trick.

The real advantage is transactional enqueue. Inserting the job in the same transaction as the row that caused it means you can never have the classic bug where the database commit succeeds and the queue publish does not. Delays, priorities and scheduled jobs are just columns and an ORDER BY. Replay is a SQL query.

Worse at: throughput, eventually. Every claim is a write, so it produces WAL and dead tuples, and autovacuum has to keep up. In our experience the shape of the wall is: comfortable in the tens to low hundreds of jobs per second on ordinary hardware, tunable into the high hundreds with batching and a partial index, and somewhere in the low thousands per second it stops being a good idea and starts being a vacuum-tuning hobby. If you are there, buy a real broker.

8. Temporal, if your problem is workflows

Worth naming because a large share of queue setups are a workflow engine that grew there by accident. If your jobs are really multi-step processes with retries per step, timers measured in days, compensation on failure, and a need to answer 'where did order 4471 get stuck', Temporal models that directly through durable execution and event-sourced history, and you stop hand-rolling state machines in a jobs table.

Worse at: being a queue. It is heavier to operate self-hosted, it is a programming model you commit to rather than a component you call, and for 'resize this image' it is enormous overkill.

The Postgres queue, concretely

We do run managed Postgres 16, so treat this section as an interested party writing it. The pattern is small enough to read in full, which is the argument for it.

CREATE TABLE jobs (
  id          bigserial PRIMARY KEY,
  kind        text        NOT NULL,
  payload     jsonb       NOT NULL,
  -- 1 = highest. Priorities are a column, not another queue.
  priority    smallint    NOT NULL DEFAULT 5,
  -- Arbitrary future timestamp. No 15-minute ceiling.
  run_at      timestamptz NOT NULL DEFAULT now(),
  attempts    int         NOT NULL DEFAULT 0,
  max_attempts int        NOT NULL DEFAULT 5,
  -- The visibility-timeout equivalent: a lease held by one worker.
  locked_until timestamptz,
  finished_at timestamptz,
  dead        boolean     NOT NULL DEFAULT false,
  last_error  text
);

-- Partial index: only rows that are actually claimable. Keeps the
-- claim query on a small index even when the table has millions of
-- finished rows waiting to be archived.
CREATE INDEX jobs_claimable
  ON jobs (priority, run_at)
  WHERE finished_at IS NULL
    AND NOT dead
    AND (locked_until IS NULL OR locked_until < now());

-- Claim a batch atomically. SKIP LOCKED is what lets N workers run
-- this same statement concurrently and get disjoint rows instead of
-- queueing behind each other.
UPDATE jobs
SET locked_until = now() + interval '5 minutes',
    attempts     = attempts + 1
WHERE id IN (
  SELECT id FROM jobs
  WHERE run_at <= now()
    AND finished_at IS NULL
    AND NOT dead
    AND (locked_until IS NULL OR locked_until < now())
    AND attempts < max_attempts
  ORDER BY priority, run_at
  FOR UPDATE SKIP LOCKED
  LIMIT 10
)
RETURNING id, kind, payload, attempts, max_attempts;

Note that attempts increments at claim time, not at failure time. That is deliberate: a worker that is killed mid-job never reports anything, and if you only counted explicit failures a job that reliably crashes the process would be retried forever. Counting on claim is how the lease expiry doubles as your dead-letter trigger, exactly as maxReceiveCount does in SQS.

The consumer is a plain loop. The only parts that need care are acking in the same transaction as the work where possible, releasing the lease on failure with backoff rather than holding it, and moving a job aside once it has burned its attempts.

import json, random, time
import psycopg
from psycopg.rows import dict_row

CLAIM = open("claim.sql").read()  # the UPDATE ... RETURNING above

def run(conn, job):
    handlers[job["kind"]](job["payload"])

def main(dsn: str):
    with psycopg.connect(dsn, row_factory=dict_row, autocommit=True) as conn:
        while True:
            with conn.transaction():
                jobs = conn.execute(CLAIM).fetchall()

            if not jobs:
                # Idle costs nothing here: no billable poll, just a sleep.
                time.sleep(1.0 + random.random())
                continue

            for job in jobs:
                try:
                    run(conn, job)
                    # Ack. Archiving beats DELETE if you ever want to
                    # answer "what ran last Tuesday".
                    conn.execute(
                        "UPDATE jobs SET locked_until = NULL,"
                        " finished_at = now() WHERE id = %s", (job["id"],))
                except Exception as exc:
                    if job["attempts"] >= job["max_attempts"]:
                        # Dead-letter: stop retrying, keep the evidence.
                        conn.execute(
                            "UPDATE jobs SET locked_until = NULL,"
                            " dead = true, last_error = %s WHERE id = %s",
                            (repr(exc)[:2000], job["id"]))
                    else:
                        # Release the lease early with exponential backoff
                        # instead of waiting out the full 5 minutes.
                        backoff = min(2 ** job["attempts"], 600)
                        conn.execute(
                            "UPDATE jobs SET locked_until = NULL,"
                            " run_at = now() + make_interval(secs => %s),"
                            " last_error = %s WHERE id = %s",
                            (backoff, repr(exc)[:2000], job["id"]))
Two things kill Postgres queues in production, and both are boring. First, never DELETE completed rows one at a time in the hot path — archive to a partition or a second table on a schedule, or autovacuum will spend its life chasing your dead tuples. Second, if a worker holds an open transaction while it does slow network I/O, it pins the xmin horizon and vacuum stops being able to clean anything on the whole database. Claim in a short transaction, then work outside it.

Where PandaStack fits, which is not the broker

We do not run a queue service and I am not going to pretend otherwise. What we run is the consumer, and that is the part this comparison usually skips.

A queue consumer is a long-lived process. It wants to hold connections open, keep a warm pool, prefetch a batch, run for however long the job takes, and shut down cleanly on SIGTERM after finishing what it has claimed. That is a bad fit for a function runtime with an execution ceiling and no persistent state between invocations, which is why so many teams end up with a Lambda that pretends to be a worker and a page at 3am about visibility timeouts. A PandaStack app is a full Ubuntu userspace in a Firecracker microVM, so the worker is just a process — you can run the web server and the worker in the same sandbox, or deploy the worker as its own app from the same repo with a different start command. Root, apt, arbitrary ports, several processes, the normal Linux things.

Pairing that with our managed Postgres 16 is the version of this with the fewest moving parts: the queue is a table in the database you already have, the worker is a process in a microVM, and there is no per-request charge on either side, so an idle poll loop is free in the way an idle SQS poll loop is not. The honest constraint: guest RAM is fixed by the template snapshot at restore time, so you pick a memory tier rather than resizing a running worker, and if your throughput is genuinely in the thousands of jobs per second you should be buying RabbitMQ or Kafka and running your consumers on us, not using the database as a broker.

Side by side

  • Stay on SQS — when the workload is 'process this task, retry on failure', volume is moderate, you are already in AWS, and you do not need delays past fifteen minutes, priorities or replay. The operational cost of every other option on this list is higher than zero.
  • RabbitMQ — richest queue semantics: routing, priorities, TTL-based delays, push consumers. Choose it when you want SQS plus features, and accept a broker with memory and disk alarms.
  • Redis queues (BullMQ, Sidekiq, RQ, Celery) — best developer experience for application background jobs, with scheduling and dashboards out of the box. Accept memory-bound backlogs and weaker durability guarantees.
  • NATS JetStream — lightest persistent option, with subject wildcards and stream replay in one small binary. Accept a smaller ecosystem and fewer managed hosts.
  • Kafka or Redpanda — a replayable log, not a queue. Choose it for event history, multiple independent consumer groups and per-key ordering. Do not choose it to run tasks.
  • Google Pub/Sub — SQS-plus-SNS in one service, with ordering keys and seek. Choose it if you are on GCP; the cross-cloud version rarely pays.
  • Cloudflare Queues — shortest path if your producers and consumers are already Workers. Constrained if consumers need long runtimes or native binaries.
  • Postgres with SKIP LOCKED — removes a component, gives transactional enqueue and SQL-native delays and priorities. Good to the high hundreds of jobs per second; past the low thousands, buy a broker.
  • Temporal — for multi-step durable workflows with timers and compensation. Overkill for single-shot jobs, and a programming model rather than a component.

How to choose in ten minutes

  1. Write down your steady-state and peak messages per second. Under a few hundred, the Postgres option is on the table and deletes a component. Above a few thousand sustained, it is not.
  2. Decide whether you need replay. If a new consumer must be able to read history, you want a log, and the queue conversation is over — go to Kafka, Redpanda or JetStream streams.
  3. Check whether your jobs are single steps or multi-step processes with timers. Multi-step with compensation is a workflow engine wearing a queue costume.
  4. Ask whether producers and consumers can share a transaction with your database. If yes, transactional enqueue in Postgres removes an entire class of 'the row committed but the message never sent' bug that no broker can fix for you.
  5. Look at what runs the consumer, not just what stores the message. If your workers need long runtimes, native binaries or a warm connection pool, pick a compute model that runs an ordinary process before you pick a broker.

The uncomfortable conclusion of most of these evaluations is that SQS was fine and the actual problem was somewhere else — a consumer that could not run long enough, a scheduler bolted on because of the delay limit, or an event history nobody kept. Fix the thing that is actually broken. Swapping brokers is the most visible change you can make and frequently not the one that helps.

Frequently asked questions

What is the closest drop-in replacement for AWS SQS?

RabbitMQ and Google Cloud Pub/Sub are the closest in semantics. RabbitMQ covers everything SQS does — durable queues, acknowledgement leases, dead-lettering — and adds routing, priorities and TTL-based delays, at the price of running or paying for a broker. Pub/Sub is the nearest managed equivalent, with topics and subscriptions giving you fan-out without a separate SNS, plus ordering keys and seek-based replay, but it only makes sense if your compute already runs on GCP. Neither is a literal drop-in: visibility-timeout handling and duplicate semantics differ enough that you will rewrite consumer code.

Can I use Postgres as a message queue instead of SQS?

Yes, and for a lot of applications it is the right call. A jobs table with SELECT ... FOR UPDATE SKIP LOCKED lets concurrent workers claim disjoint rows without blocking, giving you a durable queue with no extra infrastructure. The unique advantage is transactional enqueue: you insert the job in the same transaction as the data change that caused it, so the 'database committed but the publish failed' bug becomes impossible. Delays, priorities and scheduling are just columns. It works comfortably into the hundreds of jobs per second and stops being a good idea somewhere in the low thousands, where WAL volume and autovacuum pressure dominate.

Is Kafka a good replacement for SQS?

Usually not, because it is a different shape. Kafka is a partitioned, replayable log: consumers track offsets, nothing is deleted on read, and retention is by time or size. That gives you replay and multiple independent consumer groups, which SQS structurally cannot do. But per-message acknowledgement, redelivering one bad record, arbitrary delays and priorities are awkward or absent, and one slow message blocks its whole partition. If your workload is 'run this task and retry it on failure', Kafka means building a queue on top of a log. Choose it for event history, not for task processing.

Why does SQS get expensive at high volume?

Because billing is per API request and receives count as requests, not just sends. A fleet of consumers long-polling a mostly empty queue generates billable calls to discover there is no work, and every message costs at minimum a send, a receive and a delete. Batching up to ten messages per call is the standard mitigation and helps a great deal. Beyond that, the fix is usually architectural: fewer, fatter messages, longer poll waits, or moving to a broker or database where an idle consumer costs nothing. Verify current rates on the AWS pricing page before modelling anything.

How do I run a queue consumer without a serverless function?

Run it as an ordinary long-lived Linux process. Consumers want to hold connections open, keep a warm pool, prefetch batches, run as long as the job takes, and drain cleanly on SIGTERM — none of which fits a function runtime with an execution ceiling and no state between invocations. On PandaStack, an app is a full Ubuntu userspace in a Firecracker microVM, so a worker is just a process: run it alongside your web server in the same sandbox, or deploy it as a separate app from the same repo with a different start command. Root access, apt packages and arbitrary ports are all available.

Does PandaStack offer a managed message queue?

No. We are not a message broker and we do not sell one, so if you want a hosted RabbitMQ, Kafka or SQS-equivalent, that is someone else's product. What we run is the consumer side: long-lived worker processes in Firecracker microVMs, deployed from git, alongside or separate from your web app. We also run managed Postgres 16, which makes the SKIP LOCKED pattern in this post a practical option for moderate throughput — the queue becomes a table in a database you already have, with no extra component to operate.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.