Redis vs SQS for a Crawl Frontier Queue
Once a crawl outgrows one process, the frontier — the queue of URLs still to fetch — has to live somewhere every worker can reach. The two defaults are a Redis instance you operate and a managed queue such as SQS. Both work; they fail differently, and the comparison that matters is not throughput but which of the frontier's four responsibilities each one actually covers. This page is part of Orchestrating Distributed Crawls Across Workers.
Comparison Table
| Dimension | Redis | Amazon SQS |
|---|---|---|
| Claim semantics | SET key NX or BRPOPLPUSH, composed by you |
Receive-with-visibility-timeout, built in |
| Redelivery on worker death | A reclaim loop you run and supervise | Automatic when the visibility timeout expires |
| Deduplication | Same instance: a set or a Bloom filter | Not addressed; needs a second system |
| Ordering / priority | Explicit — one list per shard or priority | None on standard queues; FIFO trades throughput |
| Latency per operation | Sub-millisecond | Tens of milliseconds |
| Capacity | Bounded by instance memory | Effectively unbounded |
| Operational burden | Failover, persistence, maxmemory-policy |
None beyond IAM and queue configuration |
| Per-host politeness | Natural, via one list per host shard | Requires one queue per shard, or moving politeness into the worker |
| Cost shape | An instance you pay for continuously | Per request, plus per-message storage |
The routing decision in practice
#!/usr/bin/env python3
# /opt/audit/frontier/backend.py — one interface, two implementations, so the
# crawl code never encodes which backend it is talking to.
from __future__ import annotations
import abc
class Frontier(abc.ABC):
@abc.abstractmethod
def push(self, url: str, shard: str, priority: int = 0) -> None: ...
@abc.abstractmethod
def claim(self, shard: str, visibility_s: int) -> str | None: ...
@abc.abstractmethod
def ack(self, url: str, shard: str) -> None: ...
@abc.abstractmethod
def seen(self, url: str) -> bool:
"""True if this URL has been claimed before. Note that SQS has no
answer to this question — an SQS-backed frontier must compose a
separate store, which is the single biggest difference between the
two backends and the one most often discovered late."""
Defining the interface first is worth the small amount of ceremony. It forces the fourth method to exist, which forces the question of where deduplication lives to be answered before a backend is chosen rather than during the migration.
The Redis implementation covers all four responsibilities in one store:
class RedisFrontier(Frontier):
def __init__(self, r, crawl_id: str):
self.r, self.ns = r, f"crawl:{crawl_id}"
def push(self, url, shard, priority=0):
self.r.zadd(f"{self.ns}:q:{shard}", {url: -priority})
def claim(self, shard, visibility_s):
# Pop the highest-priority member and record it as in-flight, so a
# reclaim loop can return it if this worker dies before ack().
items = self.r.zpopmin(f"{self.ns}:q:{shard}", 1)
if not items:
return None
url = items[0][0].decode()
self.r.zadd(f"{self.ns}:inflight:{shard}",
{url: __import__("time").time() + visibility_s})
return url
def ack(self, url, shard):
self.r.zrem(f"{self.ns}:inflight:{shard}", url)
def seen(self, url) -> bool:
# NX makes this atomic: exactly one worker gets False for a given URL.
return not self.r.set(f"{self.ns}:seen:{url}", 1, nx=True, ex=86400 * 7)
The SQS implementation covers three of the four and has to compose the fourth:
class SqsFrontier(Frontier):
def __init__(self, sqs, queue_urls: dict[str, str], dedup_store):
self.sqs, self.queues = sqs, queue_urls
self.dedup = dedup_store # a Redis set, DynamoDB, or a Bloom filter
def push(self, url, shard, priority=0):
# Priority has no expression here; a standard queue is unordered.
self.sqs.send_message(QueueUrl=self.queues[shard], MessageBody=url)
def claim(self, shard, visibility_s):
resp = self.sqs.receive_message(
QueueUrl=self.queues[shard], MaxNumberOfMessages=1,
VisibilityTimeout=visibility_s, WaitTimeSeconds=10)
msgs = resp.get("Messages", [])
if not msgs:
return None
self._handles[msgs[0]["Body"]] = msgs[0]["ReceiptHandle"]
return msgs[0]["Body"]
def ack(self, url, shard):
self.sqs.delete_message(QueueUrl=self.queues[shard],
ReceiptHandle=self._handles.pop(url))
def seen(self, url) -> bool:
return self.dedup.check_and_add(url) # a second system, unavoidably
Two observations fall out of writing both. First, push on the SQS side quietly discards priority, which is fine for a uniform crawl and is a silent behaviour change for one that used priority to reach high-value templates first. Second, seen on the SQS side takes a dependency the Redis version does not have — so an SQS frontier is always two systems, and the operational burden it saves on the queue it partly gives back on the dedup store.
Verification
set -euo pipefail
# Run the same conformance suite against both implementations.
/opt/audit/.venv/bin/python3 -m pytest tests/test_frontier_conformance.py --backend redis --backend sqs -q
# The three properties that must hold on either backend:
# 1. A claimed URL is never handed to a second worker before its visibility expires.
# 2. A URL claimed and never acked is redelivered after the visibility timeout.
# 3. seen() returns False exactly once per URL, under concurrency.
Expected output is an identical pass count for both backends. A conformance suite that runs against both is what makes a later migration a configuration change instead of a rewrite — and it is what catches the redelivery gap on Redis, which is otherwise only discovered when a worker dies in production.
Failure Modes
Redis memory grows until the instance refuses writes
The visited set is exact and the crawl is large. Confirm maxmemory-policy is noeviction so frontier data is never silently dropped, then move the visited set to a Bloom filter as described in deduplicating URLs in a distributed crawl.
SQS redelivers URLs that were already fetched
The visibility timeout is shorter than the time a worker takes to fetch, score and ack a page. Set it from the observed p99 processing time with headroom, and extend it explicitly for long-running pages rather than raising the global value for everyone.
Per-host rate limiting stops working after a backend change
Politeness was implemented by sharding hosts across queues, and the new backend has one queue. Either shard on the new side too — one SQS queue per shard — or move per-host pacing into the worker, accepting that a worker now needs to know about hosts it does not own.
FAQ
What is the single biggest difference between the two?
Deduplication. A crawl frontier is a queue plus a visited set, and SQS only solves the queue — it has no answer to "has this URL been seen before". That means an SQS frontier is always two systems, so the operational burden it saves by being managed is partly given back by the dedup store it forces you to run. Redis covers both in one instance, which is why it remains the default for crawlers even though the queue itself is less capable.
Does SQS handle worker death better than Redis?
Yes, and it is the clearest advantage it has. The visibility timeout is built in: a message claimed and never deleted becomes visible again automatically. On Redis the same behaviour has to be built — an in-flight sorted set scored by expiry, plus a supervised reclaim loop that returns expired entries to the queue. That loop is a real process that has to run on a schedule, and the most common Redis frontier defect is having written the function and never scheduled it.
Can per-host politeness be preserved on either backend?
On Redis it is natural, because one list per host shard means the worker owning a shard sees every request to those hosts and can hold the token bucket. On SQS it requires one queue per shard, which is workable but multiplies queue count and cost, or moving pacing into the worker — at which point no single component sees the whole request rate for a host. Whichever backend is chosen, sharding by registered domain rather than by URL is what makes politeness enforceable at all.
Related
- Orchestrating Distributed Crawls Across Workers — the parent guide covering shared frontier design, sharding and graceful draining
- Sharding a URL Frontier Across Crawl Workers — the consistent-hash ring that decides which shard a URL belongs to
- Deduplicating URLs in a Distributed Crawl — the visited-set layer that SQS does not provide and Redis does