Caches, queues and background jobs
Speed reads with caches, and move slow work to queues that retry safely.
Create a free account Try part of it here
Why it matters
Two tools solve a large share of real performance and reliability problems. A cache keeps answers you've already worked out, so repeat requests are fast and cheap. That matters even more for AI features, where one model call can take seconds and cost cents. A queue takes slow or risky work out of the request: the user gets a quick reply, and workers do the job, retrying when it fails. Both are simple ideas with sharp edges. This lesson covers both.
What you'll be able to do
- Use the cache-aside pattern with expiry and LRU eviction, and measure the hit rate
- Handle the hard parts of caching: stale data, invalidation and stampedes
- Move slow work to a queue with visibility timeouts, retries and a dead-letter queue
- Explain at-least-once delivery, and why consumers must be idempotent
What it covers
- Cache-aside
- Cache-aside, with a clock
- The hard parts
- Queues and workers
- A worker that crashes at the worst moment
- LRU with OrderedDict
- Mistakes that reach production
Cache-aside
Think of the sticky note where you keep the phone numbers you use most. You check the note first. If the number isn't there, you look it up in the directory and add it to the note. This is the most common pattern. To read:
- Look in the cache. If it's there (a hit), return it.
- If not (a miss), load it from the database or service, store it in the cache, return it.
Each entry gets a time to live (TTL), after which it expires, like a "best before" date. When the cache is full, it evicts something. When your sticky note is full, you cross out the number you haven't used in the longest time, and that's LRU (least recently used).
Hit rate, the share of reads the cache can answer, is everything. With a 1 ms cache and a 50 ms database:
| hit rate | average read |
|---|---|
| 50% | 25.5 ms |
| 90% | 5.9 ms |
| 99% | 1.5 ms |
Cache-aside, with a clock
Check the cache, then go to the database on a miss, and save the answer for next time. A pretend clock shows what expiry does.
DB = {"tent": 89}
cache = {}
db_reads = 0
def get_price(sku, now, ttl=60):
global db_reads
hit = cache.get(sku)
if hit and now < hit["expires"]:
return hit["value"], "hit"
db_reads += 1
cache[sku] = {"value": DB[sku], "expires": now + ttl}
return DB[sku], "miss"
print(get_price("tent", now=0))
print(get_price("tent", now=30))
DB["tent"] = 79 # the price changes
print(get_price("tent", now=45), "still the old price")
print(get_price("tent", now=70), "after it expires")
print("database reads:", db_reads)Two of the four calls skipped the database, but the third returned a stale price. That's the trade-off a TTL makes: how long may a wrong answer live, in exchange for how much less load?
The hard parts
- Stale data. A cached value can be out of date until it expires, like an old number on your note. Choose TTLs by how stale is acceptable: seconds for stock levels, hours for a product description.
- Invalidation. When data changes, delete or update its cache entry. Missing one place means users see old data. Versioned keys (
product:42:v7) make old entries unreachable. - Stampedes. Picture a shelf running empty, and a thousand shoppers all heading for the stockroom at once. A popular entry expires, and a thousand requests miss at once and all hit the database. Fixes: let one request reload while others wait (request coalescing), add random jitter to TTLs so entries don't expire together, or refresh hot entries early.
- Keys. Include everything that changes the answer: user, language, version. A cache key without the user id can show one user's private data to another.
Caching AI calls
Model calls are slow and costly, so caching pays. Two layers:
- Prompt caching at the API: reuses the processed prefix of a request (Phase 1 and Phase 10).
- Response caching in your app: store the answer for an identical request, such as the same document summarized with the same prompt and model. Include the model and prompt version in the key, and don't cache replies that depend on the user unless the key includes the user.
recs:homepage, but the recommendations are personalized. What goes wrong?Show the answer
Every user sees the recommendations cached for whoever loaded the page first
The key must include everything that changes the answer, here the user id.
Queues and workers
A busy kitchen keeps orders on a rail of tickets. The waiter adds a ticket and goes back to the guests, and the cooks take tickets as they're free. A queue holds jobs; producers add them and workers take them. Use one when work is slow (sending email, video processing, a batch of model calls), when it can fail and should retry, or when traffic comes in bursts the workers can smooth out.
A good queue gives each job a life cycle:
- A worker pulls a job. The job becomes invisible to other workers for a visibility timeout, say 30 seconds.
- If the worker finishes, it acknowledges the job, which removes it.
- If it fails, or crashes and never acknowledges, the job becomes visible again after a delay and is retried.
- After too many attempts, the job moves to a dead-letter queue for a person to look at, so one bad job can't retry forever. A dead-letter queue holds jobs that failed too many times, set aside for a person to inspect.
At-least-once delivery
A cook finishes a dish and then gets called away before marking the ticket done, so another cook makes it again. Because a crashed worker's job is retried, a job can run more than once: the worker may have done the work and crashed before acknowledging. Most queues promise at-least-once delivery, not exactly-once. So workers must be idempotent: running a job twice has the same effect as once. Record finished job ids, use idempotency keys with payment APIs, or write with upserts, which insert a row or update it if it's already there.
In plain words: assume every job can run twice, and make sure running it twice does no harm.
Common tools: Redis-based job libraries, Amazon SQS, RabbitMQ, and Kafka (a log that consumers read at their own pace).
A worker that crashes at the worst moment
The worker charges the card, then crashes before it can acknowledge the job. The queue delivers the job again, as it should. Compare what happens with and without a check.
def run(idempotent): charges, done = [], set() deliveries = [("job-1", "crash"), ("job-1", "ack")] # delivered twice for job, then in deliveries: if idempotent and job in done: continue charges.append(job) done.add(job) if then == "crash": continue # the acknowledgment never reaches the queue return charges print("no check:", run(False)) print("with a check:", run(True))
Without a check, the customer pays twice. With one, the second delivery changes nothing. You can't avoid the repeat, since the queue can't tell whether the worker finished, so you make the repeat harmless.
Show the answer
The job is retried after the visibility timeout; an idempotency key stops a second charge
At-least-once delivery means the job runs again. The payment API's idempotency key turns the repeat into a no-op.
LRU with OrderedDict
An OrderedDict remembers order, and move_to_end marks an item as recently used.
from collections import OrderedDict cache = OrderedDict() capacity = 2 for key in ["a", "b", "a", "c"]: if key in cache: cache.move_to_end(key) print("hit ", key) else: print("miss", key) cache[key] = key.upper() if len(cache) > capacity: evicted, _ = cache.popitem(last=False) print(" evicted", evicted) print(list(cache))
"a" was used again, so "b" became the least recently used and was evicted when "c" arrived.
The second "a" is a hit, so it moves to the newest end. When "c" arrives, "b" is the least recently used and is evicted.
from collections import OrderedDict cache = OrderedDict() for key in ["a", "b", "c", "a", "d"]: if key in cache: cache.move_to_end(key) else: cache[key] = True if len(cache) > 3: cache.popitem(last=False) print(list(cache))
Show the answer
['c', 'a', 'd']
Using a moves it to the newest end, so b becomes the least recently used and is evicted when d arrives. That leaves ['c', 'a', 'd'].
Mistakes that reach production
Leaving the user out of a cache key. If /account is cached by its URL alone, the first person to load it fills the cache, and the next person sees their data. This has caused real and embarrassing leaks. Any personalized response needs the user in its key, or it shouldn't be cached at all.
Retrying forever. A job that always fails, perhaps because of a bad input, will keep going round and using up workers. After a few attempts, move it to a dead-letter queue, where a person can look at it.
A visibility timeout shorter than the job. A queue hides a job while a worker has it, for a set time. If the job takes longer than that, the queue assumes the worker died, and hands the job to another worker. Now two workers are doing it at once. Set the timeout well above your slowest job.
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
A popular cache entry expires and 2,000 requests hit the database at once. What's this called, and one fix?