[{"data":1,"prerenderedAt":2502},["ShallowReactive",2],{"page-\u002Fscaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis\u002F":3,"faq-schema-\u002Fscaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis\u002F":2481},{"id":4,"title":5,"body":6,"description":2470,"extension":2471,"meta":2472,"navigation":406,"path":2477,"seo":2478,"stem":2479,"__hash__":2480},"content\u002Fscaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis\u002Findex.md","Caching Python API Responses with Redis for Faster, Cheaper Endpoints",{"type":7,"value":8,"toc":2455},"minimark",[9,13,23,41,176,179,184,196,212,215,233,235,239,254,284,302,305,352,354,358,374,508,511,626,641,643,647,650,826,837,850,852,856,859,1074,1081,1272,1279,1294,1296,1300,1303,1679,1697,1700,1808,1811,1813,1817,1913,1926,1928,1932,1942,2030,2037,2040,2111,2124,2126,2130,2192,2194,2198,2205,2258,2261,2297,2324,2326,2330,2333,2345,2347,2351,2364,2370,2381,2387,2396,2398,2402,2407,2432,2437,2451],[10,11,5],"h1",{"id":12},"caching-python-api-responses-with-redis-for-faster-cheaper-endpoints",[14,15,16,17,22],"p",{},"A read-heavy endpoint that recomputes the same JSON on every request burns CPU, hammers your database, and inflates your hosting bill. Redis fixes that: you store the computed response once, serve it from memory for a few seconds or minutes, and let your upstream breathe. This guide walks through wiring an async Redis cache into a FastAPI service end to end — connection setup, a reusable caching dependency, the cache-aside pattern, stampede protection, and the hit-ratio math that tells you whether any of it is paying off. Part of the ",[18,19,21],"a",{"href":20},"\u002Fscaling-and-operating-production-python-apis\u002F","Scaling and Operating Production Python APIs"," guide.",[14,24,25,26,30,31,35,36,40],{},"When your cached data changes and you need to decide ",[27,28,29],"em",{},"how"," to evict it, jump to ",[18,32,34],{"href":33},"\u002Fscaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis\u002Fcache-invalidation-strategies\u002F","cache invalidation strategies"," — this page focuses on getting a correct, fast cache in place first. If you are still weighing whether you even need a network round-trip to Redis versus caching inside each worker, read ",[18,37,39],{"href":38},"\u002Fscaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis\u002Fredis-vs-in-memory-caching-for-fastapi\u002F","Redis vs in-memory caching for FastAPI"," alongside this guide.",[42,43,51,52,51,56,51,60,51,79,51,86,51,95,51,103,51,109,51,113,51,119,51,124,51,130,51,134,51,139,51,144,51,148,51,152,51,156,51,161,51,165,51,171],"svg",{"viewBox":44,"role":45,"ariaLabelledBy":46,"xmlns":49,"style":50},"0 0 720 300","img",[47,48],"redis-cache-t","redis-cache-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;height:auto;margin:1.5rem 0;font-family:var(--font-sans);","\n  ",[53,54,55],"title",{"id":47},"Cache-aside data flow",[57,58,59],"desc",{"id":48},"An incoming request checks Redis. A hit returns cached JSON immediately. A miss queries the database, populates Redis with a TTL, then returns the response.",[61,62,63,64,51],"defs",{},"\n    ",[65,66,73,74,63],"marker",{"id":67,"viewBox":68,"refX":69,"refY":70,"markerWidth":71,"markerHeight":71,"orient":72},"redis-cache-arrow","0 0 10 10","9","5","7","auto-start-reverse","\n      ",[75,76],"path",{"d":77,"fill":78},"M0 0 L10 5 L0 10 z","var(--c-text-muted)",[80,81],"rect",{"x":82,"y":82,"width":83,"height":84,"fill":85},"0","720","300","var(--c-surface)",[80,87],{"x":88,"y":89,"width":90,"height":91,"rx":92,"fill":85,"stroke":93,"style":94},"20","120","150","60","10","var(--c-blue)","stroke-width:2;",[96,97,102],"text",{"x":98,"y":99,"fill":100,"style":101},"95","155","var(--c-text)","text-anchor:middle;font-size:14;font-family:var(--font-sans);","Request",[104,105],"line",{"x1":106,"y1":90,"x2":107,"y2":90,"stroke":78,"style":108},"170","240","stroke-width:2;marker-end:url(#redis-cache-arrow);",[80,110],{"x":107,"y":89,"width":111,"height":91,"rx":92,"fill":85,"stroke":112,"style":94},"160","var(--c-teal)",[96,114,118],{"x":115,"y":116,"fill":100,"style":117},"320","146","text-anchor:middle;font-size:13;font-family:var(--font-sans);","Check Redis",[96,120,123],{"x":115,"y":121,"fill":78,"style":122},"164","text-anchor:middle;font-size:12;font-family:var(--font-sans);","by cache key",[104,125],{"x1":126,"y1":127,"x2":128,"y2":129,"stroke":78,"style":108},"400","135","470","70",[96,131,133],{"x":132,"y":98,"fill":100,"style":122},"430","HIT",[80,135],{"x":128,"y":136,"width":137,"height":138,"rx":92,"fill":85,"stroke":112,"style":94},"40","230","56",[96,140,143],{"x":141,"y":142,"fill":100,"style":117},"585","73","Return cached JSON",[104,145],{"x1":126,"y1":146,"x2":128,"y2":147,"stroke":78,"style":108},"165","210",[96,149,151],{"x":132,"y":150,"fill":100,"style":122},"205","MISS",[80,153],{"x":128,"y":154,"width":90,"height":138,"rx":92,"fill":85,"stroke":155,"style":94},"190","var(--c-coral)",[96,157,160],{"x":158,"y":159,"fill":100,"style":117},"545","223","Query DB",[104,162],{"x1":158,"y1":163,"x2":158,"y2":164,"stroke":78,"style":108},"246","280",[80,166],{"x":132,"y":164,"width":137,"height":167,"rx":168,"fill":169,"stroke":170,"style":94},"14","6","none","var(--c-yellow)",[96,172,175],{"x":158,"y":173,"fill":78,"style":174},"291","text-anchor:middle;font-size:11;font-family:var(--font-sans);","Populate Redis with TTL, then return",[177,178],"hr",{},[180,181,183],"h2",{"id":182},"what-actually-belongs-in-a-redis-cache","What actually belongs in a Redis cache",[14,185,186,187,191,192,195],{},"Before writing a line of code, be honest about what you are caching and why. A cache earns its keep on data that is ",[188,189,190],"strong",{},"read far more often than it changes"," and is ",[188,193,194],{},"expensive to produce relative to a memory lookup",". A product catalogue queried ten thousand times an hour and edited twice a day is the textbook case. A per-user notification feed that changes on every request is not — you would spend more effort invalidating it than you ever save.",[14,197,198,199,202,203,206,207,211],{},"The three properties that make a response cacheable are worth naming explicitly. First, it must be ",[188,200,201],{},"deterministic for a given key",": the same inputs produce the same output, so a stored copy is safe to reuse. Second, it must be ",[188,204,205],{},"safe to serve slightly stale"," — every cache trades freshness for speed, and if your business cannot tolerate a value being a few seconds behind, caching is the wrong tool and you want a materialized read model instead. Third, the cost of producing it must dwarf the cost of a Redis round-trip. A Redis ",[208,209,210],"code",{},"GET"," over a local network is roughly 0.2–0.5 ms; if your loader also runs in under a millisecond, the cache adds latency rather than removing it.",[14,213,214],{},"The best caching candidates in a typical commercial API are aggregation endpoints (dashboards, counts, leaderboards), responses assembled from a slow third-party API you pay per call, and expensive database joins that fan out across several tables. The worst candidates are write endpoints, anything personalized without a per-user key, and cheap primary-key lookups that Postgres already answers from its own buffer cache in microseconds. Cache the expensive reads, leave the cheap ones alone, and never cache a write.",[14,216,217,218,221,222,221,225,228,229,232],{},"There is also a strategic reason to reach for Redis specifically rather than any other store. Redis gives you atomic operations (",[208,219,220],{},"SET NX",", ",[208,223,224],{},"INCR",[208,226,227],{},"HINCRBY",") that a plain key-value store does not, and those primitives are exactly what stampede protection and hit-ratio counters are built from later in this guide. You are not just buying a fast dictionary; you are buying the atomic building blocks that make a ",[27,230,231],{},"correct"," cache under concurrency, which is why it stays the default even as your API grows from one instance to a fleet.",[177,234],{},[180,236,238],{"id":237},"prerequisites","Prerequisites",[14,240,241,242,245,246,249,250,253],{},"You need a running Redis server and the async client. Redis 7.x is the safe baseline; locally, ",[208,243,244],{},"docker run -p 6379:6379 redis:7"," is enough. Install the modern ",[208,247,248],{},"redis"," package (the old ",[208,251,252],{},"aioredis"," is now merged into it):",[255,256,261],"pre",{"className":257,"code":258,"language":259,"meta":260,"style":260},"language-bash shiki shiki-themes github-light github-dark","pip install \"redis>=5.0\" fastapi \"uvicorn[standard]\"\n","bash","",[208,262,263],{"__ignoreMap":260},[264,265,267,271,275,278,281],"span",{"class":104,"line":266},1,[264,268,270],{"class":269},"sScJk","pip",[264,272,274],{"class":273},"sZZnC"," install",[264,276,277],{"class":273}," \"redis>=5.0\"",[264,279,280],{"class":273}," fastapi",[264,282,283],{"class":273}," \"uvicorn[standard]\"\n",[14,285,286,287,291,292,296,297,301],{},"This guide assumes you already have a FastAPI app — if not, start with ",[18,288,290],{"href":289},"\u002Fgetting-started-with-python-apis-for-builders\u002Fsetting-up-fastapi\u002F","setting up FastAPI",". When the loader behind the cache is a database query, wire it through an async engine as covered in ",[18,293,295],{"href":294},"\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002F","async database access with SQLAlchemy"," so the miss path never blocks the event loop. When the loader calls a third-party API, use an async HTTP client — see ",[18,298,300],{"href":299},"\u002Fgetting-started-with-python-apis-for-builders\u002Fmaking-http-requests-with-requests-library\u002Fhttpx-vs-requests-for-async\u002F","httpx vs requests for async",".",[14,303,304],{},"Configuration comes from environment variables only — never hardcode connection strings:",[255,306,308],{"className":257,"code":307,"language":259,"meta":260,"style":260},"export REDIS_URL=\"redis:\u002F\u002Flocalhost:6379\u002F0\"\nexport CACHE_TTL_SECONDS=\"60\"\nexport CACHE_NAMESPACE=\"api:v1\"\n",[208,309,310,326,339],{"__ignoreMap":260},[264,311,312,316,320,323],{"class":104,"line":266},[264,313,315],{"class":314},"szBVR","export",[264,317,319],{"class":318},"sVt8B"," REDIS_URL",[264,321,322],{"class":314},"=",[264,324,325],{"class":273},"\"redis:\u002F\u002Flocalhost:6379\u002F0\"\n",[264,327,329,331,334,336],{"class":104,"line":328},2,[264,330,315],{"class":314},[264,332,333],{"class":318}," CACHE_TTL_SECONDS",[264,335,322],{"class":314},[264,337,338],{"class":273},"\"60\"\n",[264,340,342,344,347,349],{"class":104,"line":341},3,[264,343,315],{"class":314},[264,345,346],{"class":318}," CACHE_NAMESPACE",[264,348,322],{"class":314},[264,350,351],{"class":273},"\"api:v1\"\n",[177,353],{},[180,355,357],{"id":356},"step-1-connect-with-redisasyncio","Step 1: Connect with redis.asyncio",[14,359,360,361,365,366,369,370,373],{},"Create one connection pool for the whole process and reuse it. Opening a fresh connection per request defeats the point of caching and, under load, exhausts your Redis connection limit the same way an unbounded database pool does — a failure mode covered in ",[18,362,364],{"href":363},"\u002Fscaling-and-operating-production-python-apis\u002Fasync-database-access-with-sqlalchemy\u002Ffixing-connection-pool-exhaustion\u002F","fixing connection pool exhaustion",". Read the URL from the environment and decode responses so you get ",[208,367,368],{},"str"," back instead of ",[208,371,372],{},"bytes",":",[255,375,379],{"className":376,"code":377,"language":378,"meta":260,"style":260},"language-python shiki shiki-themes github-light github-dark","import os\nimport redis.asyncio as redis\n\nREDIS_URL = os.getenv(\"REDIS_URL\", \"redis:\u002F\u002Flocalhost:6379\u002F0\")\n\npool = redis.ConnectionPool.from_url(REDIS_URL, decode_responses=True)\n\n\ndef get_redis() -> redis.Redis:\n    \"\"\"Return a client bound to the shared pool.\"\"\"\n    return redis.Redis(connection_pool=pool)\n","python",[208,380,381,389,402,408,432,437,463,468,473,485,491],{"__ignoreMap":260},[264,382,383,386],{"class":104,"line":266},[264,384,385],{"class":314},"import",[264,387,388],{"class":318}," os\n",[264,390,391,393,396,399],{"class":104,"line":328},[264,392,385],{"class":314},[264,394,395],{"class":318}," redis.asyncio ",[264,397,398],{"class":314},"as",[264,400,401],{"class":318}," redis\n",[264,403,404],{"class":104,"line":341},[264,405,407],{"emptyLinePlaceholder":406},true,"\n",[264,409,411,415,418,421,424,426,429],{"class":104,"line":410},4,[264,412,414],{"class":413},"sj4cs","REDIS_URL",[264,416,417],{"class":314}," =",[264,419,420],{"class":318}," os.getenv(",[264,422,423],{"class":273},"\"REDIS_URL\"",[264,425,221],{"class":318},[264,427,428],{"class":273},"\"redis:\u002F\u002Flocalhost:6379\u002F0\"",[264,430,431],{"class":318},")\n",[264,433,435],{"class":104,"line":434},5,[264,436,407],{"emptyLinePlaceholder":406},[264,438,440,443,445,448,450,452,456,458,461],{"class":104,"line":439},6,[264,441,442],{"class":318},"pool ",[264,444,322],{"class":314},[264,446,447],{"class":318}," redis.ConnectionPool.from_url(",[264,449,414],{"class":413},[264,451,221],{"class":318},[264,453,455],{"class":454},"s4XuR","decode_responses",[264,457,322],{"class":314},[264,459,460],{"class":413},"True",[264,462,431],{"class":318},[264,464,466],{"class":104,"line":465},7,[264,467,407],{"emptyLinePlaceholder":406},[264,469,471],{"class":104,"line":470},8,[264,472,407],{"emptyLinePlaceholder":406},[264,474,476,479,482],{"class":104,"line":475},9,[264,477,478],{"class":314},"def",[264,480,481],{"class":269}," get_redis",[264,483,484],{"class":318},"() -> redis.Redis:\n",[264,486,488],{"class":104,"line":487},10,[264,489,490],{"class":273},"    \"\"\"Return a client bound to the shared pool.\"\"\"\n",[264,492,494,497,500,503,505],{"class":104,"line":493},11,[264,495,496],{"class":314},"    return",[264,498,499],{"class":318}," redis.Redis(",[264,501,502],{"class":454},"connection_pool",[264,504,322],{"class":314},[264,506,507],{"class":318},"pool)\n",[14,509,510],{},"Wire a health check into your FastAPI lifespan so the app fails fast if Redis is unreachable rather than discovering it on the first cache miss:",[255,512,514],{"className":376,"code":513,"language":378,"meta":260,"style":260},"from contextlib import asynccontextmanager\nfrom fastapi import FastAPI\n\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n    client = get_redis()\n    await client.ping()\n    yield\n    await pool.aclose()\n\n\napp = FastAPI(lifespan=lifespan)\n",[208,515,516,529,541,545,549,554,568,578,586,591,598,602,607],{"__ignoreMap":260},[264,517,518,521,524,526],{"class":104,"line":266},[264,519,520],{"class":314},"from",[264,522,523],{"class":318}," contextlib ",[264,525,385],{"class":314},[264,527,528],{"class":318}," asynccontextmanager\n",[264,530,531,533,536,538],{"class":104,"line":328},[264,532,520],{"class":314},[264,534,535],{"class":318}," fastapi ",[264,537,385],{"class":314},[264,539,540],{"class":318}," FastAPI\n",[264,542,543],{"class":104,"line":341},[264,544,407],{"emptyLinePlaceholder":406},[264,546,547],{"class":104,"line":410},[264,548,407],{"emptyLinePlaceholder":406},[264,550,551],{"class":104,"line":434},[264,552,553],{"class":269},"@asynccontextmanager\n",[264,555,556,559,562,565],{"class":104,"line":439},[264,557,558],{"class":314},"async",[264,560,561],{"class":314}," def",[264,563,564],{"class":269}," lifespan",[264,566,567],{"class":318},"(app: FastAPI):\n",[264,569,570,573,575],{"class":104,"line":465},[264,571,572],{"class":318},"    client ",[264,574,322],{"class":314},[264,576,577],{"class":318}," get_redis()\n",[264,579,580,583],{"class":104,"line":470},[264,581,582],{"class":314},"    await",[264,584,585],{"class":318}," client.ping()\n",[264,587,588],{"class":104,"line":475},[264,589,590],{"class":314},"    yield\n",[264,592,593,595],{"class":104,"line":487},[264,594,582],{"class":314},[264,596,597],{"class":318}," pool.aclose()\n",[264,599,600],{"class":104,"line":493},[264,601,407],{"emptyLinePlaceholder":406},[264,603,605],{"class":104,"line":604},12,[264,606,407],{"emptyLinePlaceholder":406},[264,608,610,613,615,618,621,623],{"class":104,"line":609},13,[264,611,612],{"class":318},"app ",[264,614,322],{"class":314},[264,616,617],{"class":318}," FastAPI(",[264,619,620],{"class":454},"lifespan",[264,622,322],{"class":314},[264,624,625],{"class":318},"lifespan)\n",[14,627,628,629,632,633,636,637,640],{},"One decision you must make on day one is what happens when Redis is ",[27,630,631],{},"down"," in production. The default here fails startup, which is correct — but a cache should never be a hard dependency for serving traffic. Once you are live, wrap the cache read in a ",[208,634,635],{},"try\u002Fexcept redis.RedisError"," and fall through to the loader on any Redis error, logging the failure. A cache outage should degrade you to \"slow but correct\", never to a 500. The ",[208,638,639],{},"ping()"," on startup catches misconfiguration; the runtime fallback catches a mid-day Redis restart.",[177,642],{},[180,644,646],{"id":645},"step-2-build-a-cache-key-and-serialize-json","Step 2: Build a cache key and serialize JSON",[14,648,649],{},"A cache entry needs a stable, collision-free key. Namespace it so you can flush a version cleanly, and fold in any query parameters that change the response. Store the value as a JSON string — Redis stores text, not Python objects.",[255,651,653],{"className":376,"code":652,"language":378,"meta":260,"style":260},"import hashlib\nimport json\nimport os\n\nNAMESPACE = os.getenv(\"CACHE_NAMESPACE\", \"api:v1\")\n\n\ndef cache_key(route: str, **params) -> str:\n    \"\"\"Deterministic key from route + sorted params.\"\"\"\n    raw = json.dumps(params, sort_keys=True, separators=(\",\", \":\"))\n    digest = hashlib.sha256(raw.encode()).hexdigest()[:16]\n    return f\"{NAMESPACE}:{route}:{digest}\"\n",[208,654,655,662,669,675,679,698,702,706,731,736,774,790],{"__ignoreMap":260},[264,656,657,659],{"class":104,"line":266},[264,658,385],{"class":314},[264,660,661],{"class":318}," hashlib\n",[264,663,664,666],{"class":104,"line":328},[264,665,385],{"class":314},[264,667,668],{"class":318}," json\n",[264,670,671,673],{"class":104,"line":341},[264,672,385],{"class":314},[264,674,388],{"class":318},[264,676,677],{"class":104,"line":410},[264,678,407],{"emptyLinePlaceholder":406},[264,680,681,684,686,688,691,693,696],{"class":104,"line":434},[264,682,683],{"class":413},"NAMESPACE",[264,685,417],{"class":314},[264,687,420],{"class":318},[264,689,690],{"class":273},"\"CACHE_NAMESPACE\"",[264,692,221],{"class":318},[264,694,695],{"class":273},"\"api:v1\"",[264,697,431],{"class":318},[264,699,700],{"class":104,"line":439},[264,701,407],{"emptyLinePlaceholder":406},[264,703,704],{"class":104,"line":465},[264,705,407],{"emptyLinePlaceholder":406},[264,707,708,710,713,716,718,720,723,726,728],{"class":104,"line":470},[264,709,478],{"class":314},[264,711,712],{"class":269}," cache_key",[264,714,715],{"class":318},"(route: ",[264,717,368],{"class":413},[264,719,221],{"class":318},[264,721,722],{"class":314},"**",[264,724,725],{"class":318},"params) -> ",[264,727,368],{"class":413},[264,729,730],{"class":318},":\n",[264,732,733],{"class":104,"line":475},[264,734,735],{"class":273},"    \"\"\"Deterministic key from route + sorted params.\"\"\"\n",[264,737,738,741,743,746,749,751,753,755,758,760,763,766,768,771],{"class":104,"line":487},[264,739,740],{"class":318},"    raw ",[264,742,322],{"class":314},[264,744,745],{"class":318}," json.dumps(params, ",[264,747,748],{"class":454},"sort_keys",[264,750,322],{"class":314},[264,752,460],{"class":413},[264,754,221],{"class":318},[264,756,757],{"class":454},"separators",[264,759,322],{"class":314},[264,761,762],{"class":318},"(",[264,764,765],{"class":273},"\",\"",[264,767,221],{"class":318},[264,769,770],{"class":273},"\":\"",[264,772,773],{"class":318},"))\n",[264,775,776,779,781,784,787],{"class":104,"line":493},[264,777,778],{"class":318},"    digest ",[264,780,322],{"class":314},[264,782,783],{"class":318}," hashlib.sha256(raw.encode()).hexdigest()[:",[264,785,786],{"class":413},"16",[264,788,789],{"class":318},"]\n",[264,791,792,794,797,800,803,805,808,811,814,816,818,821,823],{"class":104,"line":604},[264,793,496],{"class":314},[264,795,796],{"class":314}," f",[264,798,799],{"class":273},"\"",[264,801,802],{"class":413},"{NAMESPACE}",[264,804,373],{"class":273},[264,806,807],{"class":413},"{",[264,809,810],{"class":318},"route",[264,812,813],{"class":413},"}",[264,815,373],{"class":273},[264,817,807],{"class":413},[264,819,820],{"class":318},"digest",[264,822,813],{"class":413},[264,824,825],{"class":273},"\"\n",[14,827,828,829,832,833,836],{},"Sorting the params keeps ",[208,830,831],{},"?a=1&b=2"," and ",[208,834,835],{},"?b=2&a=1"," mapping to the same key. Truncating the SHA-256 digest keeps keys short while staying effectively collision-free for this use — a 16-hex-character prefix is 64 bits of entropy, so you would need billions of distinct parameter sets on a single route before a collision is even statistically plausible.",[14,838,839,840,843,844,847,848,301],{},"The namespace prefix is the cheapest invalidation tool you have. Bumping ",[208,841,842],{},"api:v1"," to ",[208,845,846],{},"api:v2"," on a deploy orphans every old key at once, and because they still carry TTLs, Redis evicts them on its own within minutes. That single-character change is often all the \"invalidation\" a small API needs; the heavier, event-driven approaches only earn their complexity once staleness starts costing you money, which is exactly the trade-off weighed in ",[18,849,34],{"href":33},[177,851],{},[180,853,855],{"id":854},"step-3-implement-the-cache-aside-pattern","Step 3: Implement the cache-aside pattern",[14,857,858],{},"Cache-aside (also called lazy loading) is the default for read-heavy APIs: on a request, look in Redis first; on a miss, compute the value, write it back with a TTL, and return it. The compute function stays ignorant of caching, which keeps it testable in isolation.",[255,860,862],{"className":376,"code":861,"language":378,"meta":260,"style":260},"from typing import Awaitable, Callable\n\nCACHE_TTL = int(os.getenv(\"CACHE_TTL_SECONDS\", \"60\"))\n\n\nasync def cache_aside(\n    client: redis.Redis,\n    key: str,\n    loader: Callable[[], Awaitable[dict]],\n    ttl: int = CACHE_TTL,\n) -> tuple[dict, bool]:\n    \"\"\"Return (value, hit). On miss, run loader and populate Redis.\"\"\"\n    cached = await client.get(key)\n    if cached is not None:\n        return json.loads(cached), True\n\n    value = await loader()\n    await client.set(key, json.dumps(value), ex=ttl)\n    return value, False\n",[208,863,864,876,880,903,907,911,923,928,938,949,964,979,984,997,1017,1029,1034,1047,1063],{"__ignoreMap":260},[264,865,866,868,871,873],{"class":104,"line":266},[264,867,520],{"class":314},[264,869,870],{"class":318}," typing ",[264,872,385],{"class":314},[264,874,875],{"class":318}," Awaitable, Callable\n",[264,877,878],{"class":104,"line":328},[264,879,407],{"emptyLinePlaceholder":406},[264,881,882,885,887,890,893,896,898,901],{"class":104,"line":341},[264,883,884],{"class":413},"CACHE_TTL",[264,886,417],{"class":314},[264,888,889],{"class":413}," int",[264,891,892],{"class":318},"(os.getenv(",[264,894,895],{"class":273},"\"CACHE_TTL_SECONDS\"",[264,897,221],{"class":318},[264,899,900],{"class":273},"\"60\"",[264,902,773],{"class":318},[264,904,905],{"class":104,"line":410},[264,906,407],{"emptyLinePlaceholder":406},[264,908,909],{"class":104,"line":434},[264,910,407],{"emptyLinePlaceholder":406},[264,912,913,915,917,920],{"class":104,"line":439},[264,914,558],{"class":314},[264,916,561],{"class":314},[264,918,919],{"class":269}," cache_aside",[264,921,922],{"class":318},"(\n",[264,924,925],{"class":104,"line":465},[264,926,927],{"class":318},"    client: redis.Redis,\n",[264,929,930,933,935],{"class":104,"line":470},[264,931,932],{"class":318},"    key: ",[264,934,368],{"class":413},[264,936,937],{"class":318},",\n",[264,939,940,943,946],{"class":104,"line":475},[264,941,942],{"class":318},"    loader: Callable[[], Awaitable[",[264,944,945],{"class":413},"dict",[264,947,948],{"class":318},"]],\n",[264,950,951,954,957,959,962],{"class":104,"line":487},[264,952,953],{"class":318},"    ttl: ",[264,955,956],{"class":413},"int",[264,958,417],{"class":314},[264,960,961],{"class":413}," CACHE_TTL",[264,963,937],{"class":318},[264,965,966,969,971,973,976],{"class":104,"line":493},[264,967,968],{"class":318},") -> tuple[",[264,970,945],{"class":413},[264,972,221],{"class":318},[264,974,975],{"class":413},"bool",[264,977,978],{"class":318},"]:\n",[264,980,981],{"class":104,"line":604},[264,982,983],{"class":273},"    \"\"\"Return (value, hit). On miss, run loader and populate Redis.\"\"\"\n",[264,985,986,989,991,994],{"class":104,"line":609},[264,987,988],{"class":318},"    cached ",[264,990,322],{"class":314},[264,992,993],{"class":314}," await",[264,995,996],{"class":318}," client.get(key)\n",[264,998,1000,1003,1006,1009,1012,1015],{"class":104,"line":999},14,[264,1001,1002],{"class":314},"    if",[264,1004,1005],{"class":318}," cached ",[264,1007,1008],{"class":314},"is",[264,1010,1011],{"class":314}," not",[264,1013,1014],{"class":413}," None",[264,1016,730],{"class":318},[264,1018,1020,1023,1026],{"class":104,"line":1019},15,[264,1021,1022],{"class":314},"        return",[264,1024,1025],{"class":318}," json.loads(cached), ",[264,1027,1028],{"class":413},"True\n",[264,1030,1032],{"class":104,"line":1031},16,[264,1033,407],{"emptyLinePlaceholder":406},[264,1035,1037,1040,1042,1044],{"class":104,"line":1036},17,[264,1038,1039],{"class":318},"    value ",[264,1041,322],{"class":314},[264,1043,993],{"class":314},[264,1045,1046],{"class":318}," loader()\n",[264,1048,1050,1052,1055,1058,1060],{"class":104,"line":1049},18,[264,1051,582],{"class":314},[264,1053,1054],{"class":318}," client.set(key, json.dumps(value), ",[264,1056,1057],{"class":454},"ex",[264,1059,322],{"class":314},[264,1061,1062],{"class":318},"ttl)\n",[264,1064,1066,1068,1071],{"class":104,"line":1065},19,[264,1067,496],{"class":314},[264,1069,1070],{"class":318}," value, ",[264,1072,1073],{"class":413},"False\n",[14,1075,1076,1077,1080],{},"Use it from a route. The ",[208,1078,1079],{},"loader"," is your real data source — a database query, an aggregation, or an upstream API call:",[255,1082,1084],{"className":376,"code":1083,"language":378,"meta":260,"style":260},"from fastapi import Depends, Response\n\n\n@app.get(\"\u002Fproducts\u002F{product_id}\")\nasync def get_product(\n    product_id: int,\n    response: Response,\n    client: redis.Redis = Depends(get_redis),\n):\n    key = cache_key(\"product\", id=product_id)\n\n    async def loader() -> dict:\n        # Replace with your DB query or upstream call\n        return await fetch_product_from_db(product_id)\n\n    data, hit = await cache_aside(client, key, loader)\n    response.headers[\"X-Cache\"] = \"HIT\" if hit else \"MISS\"\n    return data\n",[208,1085,1086,1097,1101,1105,1122,1133,1142,1147,1157,1162,1185,1189,1206,1212,1221,1225,1237,1265],{"__ignoreMap":260},[264,1087,1088,1090,1092,1094],{"class":104,"line":266},[264,1089,520],{"class":314},[264,1091,535],{"class":318},[264,1093,385],{"class":314},[264,1095,1096],{"class":318}," Depends, Response\n",[264,1098,1099],{"class":104,"line":328},[264,1100,407],{"emptyLinePlaceholder":406},[264,1102,1103],{"class":104,"line":341},[264,1104,407],{"emptyLinePlaceholder":406},[264,1106,1107,1110,1112,1115,1118,1120],{"class":104,"line":410},[264,1108,1109],{"class":269},"@app.get",[264,1111,762],{"class":318},[264,1113,1114],{"class":273},"\"\u002Fproducts\u002F",[264,1116,1117],{"class":413},"{product_id}",[264,1119,799],{"class":273},[264,1121,431],{"class":318},[264,1123,1124,1126,1128,1131],{"class":104,"line":434},[264,1125,558],{"class":314},[264,1127,561],{"class":314},[264,1129,1130],{"class":269}," get_product",[264,1132,922],{"class":318},[264,1134,1135,1138,1140],{"class":104,"line":439},[264,1136,1137],{"class":318},"    product_id: ",[264,1139,956],{"class":413},[264,1141,937],{"class":318},[264,1143,1144],{"class":104,"line":465},[264,1145,1146],{"class":318},"    response: Response,\n",[264,1148,1149,1152,1154],{"class":104,"line":470},[264,1150,1151],{"class":318},"    client: redis.Redis ",[264,1153,322],{"class":314},[264,1155,1156],{"class":318}," Depends(get_redis),\n",[264,1158,1159],{"class":104,"line":475},[264,1160,1161],{"class":318},"):\n",[264,1163,1164,1167,1169,1172,1175,1177,1180,1182],{"class":104,"line":487},[264,1165,1166],{"class":318},"    key ",[264,1168,322],{"class":314},[264,1170,1171],{"class":318}," cache_key(",[264,1173,1174],{"class":273},"\"product\"",[264,1176,221],{"class":318},[264,1178,1179],{"class":454},"id",[264,1181,322],{"class":314},[264,1183,1184],{"class":318},"product_id)\n",[264,1186,1187],{"class":104,"line":493},[264,1188,407],{"emptyLinePlaceholder":406},[264,1190,1191,1194,1196,1199,1202,1204],{"class":104,"line":604},[264,1192,1193],{"class":314},"    async",[264,1195,561],{"class":314},[264,1197,1198],{"class":269}," loader",[264,1200,1201],{"class":318},"() -> ",[264,1203,945],{"class":413},[264,1205,730],{"class":318},[264,1207,1208],{"class":104,"line":609},[264,1209,1211],{"class":1210},"sJ8bj","        # Replace with your DB query or upstream call\n",[264,1213,1214,1216,1218],{"class":104,"line":999},[264,1215,1022],{"class":314},[264,1217,993],{"class":314},[264,1219,1220],{"class":318}," fetch_product_from_db(product_id)\n",[264,1222,1223],{"class":104,"line":1019},[264,1224,407],{"emptyLinePlaceholder":406},[264,1226,1227,1230,1232,1234],{"class":104,"line":1031},[264,1228,1229],{"class":318},"    data, hit ",[264,1231,322],{"class":314},[264,1233,993],{"class":314},[264,1235,1236],{"class":318}," cache_aside(client, key, loader)\n",[264,1238,1239,1242,1245,1248,1250,1253,1256,1259,1262],{"class":104,"line":1036},[264,1240,1241],{"class":318},"    response.headers[",[264,1243,1244],{"class":273},"\"X-Cache\"",[264,1246,1247],{"class":318},"] ",[264,1249,322],{"class":314},[264,1251,1252],{"class":273}," \"HIT\"",[264,1254,1255],{"class":314}," if",[264,1257,1258],{"class":318}," hit ",[264,1260,1261],{"class":314},"else",[264,1263,1264],{"class":273}," \"MISS\"\n",[264,1266,1267,1269],{"class":104,"line":1049},[264,1268,496],{"class":314},[264,1270,1271],{"class":318}," data\n",[14,1273,1274,1275,1278],{},"The ",[208,1276,1277],{},"X-Cache"," header makes hits observable from the outside, which you'll use in verification below. It also matters commercially: when a customer complains their data looks stale, that header instantly tells you whether they hit a cached copy or a fresh compute, turning a vague \"it's wrong\" ticket into a two-minute diagnosis.",[14,1280,1281,1282,1285,1286,1289,1290,1293],{},"One subtle correctness rule lives in this pattern: ",[188,1283,1284],{},"never cache under a key that does not capture every input that varies the output",". If your endpoint returns different data by ",[208,1287,1288],{},"Accept-Language",", tenant, or currency, those must all be folded into ",[208,1291,1292],{},"cache_key(...)",". A key that is too coarse serves the wrong body to the wrong caller — the same class of bug as caching authenticated responses, covered in the gotchas below.",[177,1295],{},[180,1297,1299],{"id":1298},"step-4-add-stampede-protection-with-a-lock","Step 4: Add stampede protection with a lock",[14,1301,1302],{},"When a hot key expires, every concurrent request misses at once and all of them slam the database — a thundering herd. On a single popular endpoint this can turn a routine TTL expiry into a self-inflicted outage: one moment you serve everything from memory, the next you fire hundreds of identical expensive queries in the same tick. Guard the recompute with a short-lived Redis lock so only one request rebuilds the value while the others wait briefly and re-read:",[255,1304,1306],{"className":376,"code":1305,"language":378,"meta":260,"style":260},"import asyncio\nimport uuid\n\n\nasync def cached_with_lock(\n    client: redis.Redis,\n    key: str,\n    loader: Callable[[], Awaitable[dict]],\n    ttl: int = CACHE_TTL,\n    lock_ttl: int = 5,\n) -> tuple[dict, bool]:\n    cached = await client.get(key)\n    if cached is not None:\n        return json.loads(cached), True\n\n    lock_key = f\"{key}:lock\"\n    token = uuid.uuid4().hex\n    got_lock = await client.set(lock_key, token, nx=True, ex=lock_ttl)\n\n    if not got_lock:\n        # Someone else is rebuilding; wait and re-read.\n        for _ in range(lock_ttl * 10):\n            await asyncio.sleep(0.1)\n            cached = await client.get(key)\n            if cached is not None:\n                return json.loads(cached), True\n\n    try:\n        value = await loader()\n        await client.set(key, json.dumps(value), ex=ttl)\n        return value, False\n    finally:\n        # Only release if we still own the lock.\n        if await client.get(lock_key) == token:\n            await client.delete(lock_key)\n",[208,1307,1308,1315,1322,1326,1330,1341,1345,1353,1361,1373,1387,1399,1409,1423,1431,1435,1456,1466,1494,1498,1508,1514,1540,1554,1566,1582,1592,1597,1605,1617,1631,1640,1648,1654,1671],{"__ignoreMap":260},[264,1309,1310,1312],{"class":104,"line":266},[264,1311,385],{"class":314},[264,1313,1314],{"class":318}," asyncio\n",[264,1316,1317,1319],{"class":104,"line":328},[264,1318,385],{"class":314},[264,1320,1321],{"class":318}," uuid\n",[264,1323,1324],{"class":104,"line":341},[264,1325,407],{"emptyLinePlaceholder":406},[264,1327,1328],{"class":104,"line":410},[264,1329,407],{"emptyLinePlaceholder":406},[264,1331,1332,1334,1336,1339],{"class":104,"line":434},[264,1333,558],{"class":314},[264,1335,561],{"class":314},[264,1337,1338],{"class":269}," cached_with_lock",[264,1340,922],{"class":318},[264,1342,1343],{"class":104,"line":439},[264,1344,927],{"class":318},[264,1346,1347,1349,1351],{"class":104,"line":465},[264,1348,932],{"class":318},[264,1350,368],{"class":413},[264,1352,937],{"class":318},[264,1354,1355,1357,1359],{"class":104,"line":470},[264,1356,942],{"class":318},[264,1358,945],{"class":413},[264,1360,948],{"class":318},[264,1362,1363,1365,1367,1369,1371],{"class":104,"line":475},[264,1364,953],{"class":318},[264,1366,956],{"class":413},[264,1368,417],{"class":314},[264,1370,961],{"class":413},[264,1372,937],{"class":318},[264,1374,1375,1378,1380,1382,1385],{"class":104,"line":487},[264,1376,1377],{"class":318},"    lock_ttl: ",[264,1379,956],{"class":413},[264,1381,417],{"class":314},[264,1383,1384],{"class":413}," 5",[264,1386,937],{"class":318},[264,1388,1389,1391,1393,1395,1397],{"class":104,"line":493},[264,1390,968],{"class":318},[264,1392,945],{"class":413},[264,1394,221],{"class":318},[264,1396,975],{"class":413},[264,1398,978],{"class":318},[264,1400,1401,1403,1405,1407],{"class":104,"line":604},[264,1402,988],{"class":318},[264,1404,322],{"class":314},[264,1406,993],{"class":314},[264,1408,996],{"class":318},[264,1410,1411,1413,1415,1417,1419,1421],{"class":104,"line":609},[264,1412,1002],{"class":314},[264,1414,1005],{"class":318},[264,1416,1008],{"class":314},[264,1418,1011],{"class":314},[264,1420,1014],{"class":413},[264,1422,730],{"class":318},[264,1424,1425,1427,1429],{"class":104,"line":999},[264,1426,1022],{"class":314},[264,1428,1025],{"class":318},[264,1430,1028],{"class":413},[264,1432,1433],{"class":104,"line":1019},[264,1434,407],{"emptyLinePlaceholder":406},[264,1436,1437,1440,1442,1444,1446,1448,1451,1453],{"class":104,"line":1031},[264,1438,1439],{"class":318},"    lock_key ",[264,1441,322],{"class":314},[264,1443,796],{"class":314},[264,1445,799],{"class":273},[264,1447,807],{"class":413},[264,1449,1450],{"class":318},"key",[264,1452,813],{"class":413},[264,1454,1455],{"class":273},":lock\"\n",[264,1457,1458,1461,1463],{"class":104,"line":1036},[264,1459,1460],{"class":318},"    token ",[264,1462,322],{"class":314},[264,1464,1465],{"class":318}," uuid.uuid4().hex\n",[264,1467,1468,1471,1473,1475,1478,1481,1483,1485,1487,1489,1491],{"class":104,"line":1049},[264,1469,1470],{"class":318},"    got_lock ",[264,1472,322],{"class":314},[264,1474,993],{"class":314},[264,1476,1477],{"class":318}," client.set(lock_key, token, ",[264,1479,1480],{"class":454},"nx",[264,1482,322],{"class":314},[264,1484,460],{"class":413},[264,1486,221],{"class":318},[264,1488,1057],{"class":454},[264,1490,322],{"class":314},[264,1492,1493],{"class":318},"lock_ttl)\n",[264,1495,1496],{"class":104,"line":1065},[264,1497,407],{"emptyLinePlaceholder":406},[264,1499,1501,1503,1505],{"class":104,"line":1500},20,[264,1502,1002],{"class":314},[264,1504,1011],{"class":314},[264,1506,1507],{"class":318}," got_lock:\n",[264,1509,1511],{"class":104,"line":1510},21,[264,1512,1513],{"class":1210},"        # Someone else is rebuilding; wait and re-read.\n",[264,1515,1517,1520,1523,1526,1529,1532,1535,1538],{"class":104,"line":1516},22,[264,1518,1519],{"class":314},"        for",[264,1521,1522],{"class":318}," _ ",[264,1524,1525],{"class":314},"in",[264,1527,1528],{"class":413}," range",[264,1530,1531],{"class":318},"(lock_ttl ",[264,1533,1534],{"class":314},"*",[264,1536,1537],{"class":413}," 10",[264,1539,1161],{"class":318},[264,1541,1543,1546,1549,1552],{"class":104,"line":1542},23,[264,1544,1545],{"class":314},"            await",[264,1547,1548],{"class":318}," asyncio.sleep(",[264,1550,1551],{"class":413},"0.1",[264,1553,431],{"class":318},[264,1555,1557,1560,1562,1564],{"class":104,"line":1556},24,[264,1558,1559],{"class":318},"            cached ",[264,1561,322],{"class":314},[264,1563,993],{"class":314},[264,1565,996],{"class":318},[264,1567,1569,1572,1574,1576,1578,1580],{"class":104,"line":1568},25,[264,1570,1571],{"class":314},"            if",[264,1573,1005],{"class":318},[264,1575,1008],{"class":314},[264,1577,1011],{"class":314},[264,1579,1014],{"class":413},[264,1581,730],{"class":318},[264,1583,1585,1588,1590],{"class":104,"line":1584},26,[264,1586,1587],{"class":314},"                return",[264,1589,1025],{"class":318},[264,1591,1028],{"class":413},[264,1593,1595],{"class":104,"line":1594},27,[264,1596,407],{"emptyLinePlaceholder":406},[264,1598,1600,1603],{"class":104,"line":1599},28,[264,1601,1602],{"class":314},"    try",[264,1604,730],{"class":318},[264,1606,1608,1611,1613,1615],{"class":104,"line":1607},29,[264,1609,1610],{"class":318},"        value ",[264,1612,322],{"class":314},[264,1614,993],{"class":314},[264,1616,1046],{"class":318},[264,1618,1620,1623,1625,1627,1629],{"class":104,"line":1619},30,[264,1621,1622],{"class":314},"        await",[264,1624,1054],{"class":318},[264,1626,1057],{"class":454},[264,1628,322],{"class":314},[264,1630,1062],{"class":318},[264,1632,1634,1636,1638],{"class":104,"line":1633},31,[264,1635,1022],{"class":314},[264,1637,1070],{"class":318},[264,1639,1073],{"class":413},[264,1641,1643,1646],{"class":104,"line":1642},32,[264,1644,1645],{"class":314},"    finally",[264,1647,730],{"class":318},[264,1649,1651],{"class":104,"line":1650},33,[264,1652,1653],{"class":1210},"        # Only release if we still own the lock.\n",[264,1655,1657,1660,1662,1665,1668],{"class":104,"line":1656},34,[264,1658,1659],{"class":314},"        if",[264,1661,993],{"class":314},[264,1663,1664],{"class":318}," client.get(lock_key) ",[264,1666,1667],{"class":314},"==",[264,1669,1670],{"class":318}," token:\n",[264,1672,1674,1676],{"class":104,"line":1673},35,[264,1675,1545],{"class":314},[264,1677,1678],{"class":318}," client.delete(lock_key)\n",[14,1680,1274,1681,1684,1685,1688,1689,1692,1693,1696],{},[208,1682,1683],{},"nx=True"," flag makes the ",[208,1686,1687],{},"set"," atomic — exactly one caller wins the lock. The token check on release prevents one request from deleting a lock that another already re-acquired after a timeout. That token is not paranoia: without it, a slow loader that overruns ",[208,1690,1691],{},"lock_ttl"," lets a second request grab the lock, and the first request's ",[208,1694,1695],{},"finally"," block would then delete a lock it no longer owns, defeating the whole guard.",[14,1698,1699],{},"The sequence below traces three requests hitting an expired key at the same instant. Only Request A does real work; B and C poll and get served the value A just wrote.",[42,1701,51,1706,51,1709,51,1712,51,1719,51,1721,51,1726,51,1731,51,1734,51,1737,51,1739,51,1743,51,1747,51,1751,51,1755,51,1757,51,1759,51,1761,51,1765,51,1769,51,1774,51,1776,51,1781,51,1784,51,1788,51,1790,51,1794,51,1797,51,1800,51,1804],{"viewBox":1702,"role":45,"ariaLabelledBy":1703,"xmlns":49,"style":50},"0 0 720 320",[1704,1705],"redis-cache-seq-t","redis-cache-seq-d",[53,1707,1708],{"id":1704},"Stampede protection sequence",[57,1710,1711],{"id":1705},"Three requests miss an expired key at once. Request A wins the Redis lock, queries the database, and writes the value. Requests B and C fail the lock, poll Redis, and are served the value A wrote.",[61,1713,63,1714,51],{},[65,1715,73,1717,63],{"id":1716,"viewBox":68,"refX":69,"refY":70,"markerWidth":71,"markerHeight":71,"orient":72},"redis-cache-seq-arrow",[75,1718],{"d":77,"fill":78},[80,1720],{"x":82,"y":82,"width":83,"height":115,"fill":85},[80,1722],{"x":136,"y":1723,"width":89,"height":1724,"rx":1725,"fill":85,"stroke":93,"style":94},"26","30","8",[96,1727,1730],{"x":1728,"y":1729,"fill":100,"style":122},"100","46","Request A",[80,1732],{"x":1733,"y":1723,"width":89,"height":1724,"rx":1725,"fill":85,"stroke":93,"style":94},"220",[96,1735,1736],{"x":164,"y":1729,"fill":100,"style":122},"Requests B, C",[80,1738],{"x":126,"y":1723,"width":89,"height":1724,"rx":1725,"fill":85,"stroke":112,"style":94},[96,1740,1742],{"x":1741,"y":1729,"fill":100,"style":122},"460","Redis",[80,1744],{"x":1745,"y":1723,"width":1746,"height":1724,"rx":1725,"fill":85,"stroke":155,"style":94},"580","110",[96,1748,1750],{"x":1749,"y":1729,"fill":100,"style":122},"635","Database",[104,1752],{"x1":1728,"y1":138,"x2":1728,"y2":84,"stroke":1753,"style":1754},"var(--c-border)","stroke-width:1;",[104,1756],{"x1":164,"y1":138,"x2":164,"y2":84,"stroke":1753,"style":1754},[104,1758],{"x1":1741,"y1":138,"x2":1741,"y2":84,"stroke":1753,"style":1754},[104,1760],{"x1":1749,"y1":138,"x2":1749,"y2":84,"stroke":1753,"style":1754},[96,1762,1764],{"x":164,"y":1763,"fill":78,"style":174},"80","key expired — all three miss",[104,1766],{"x1":1728,"y1":1728,"x2":1767,"y2":1728,"stroke":78,"style":1768},"453","stroke-width:2;marker-end:url(#redis-cache-seq-arrow);",[96,1770,1773],{"x":1771,"y":1772,"fill":100,"style":174},"270","94","SET NX lock — won",[104,1775],{"x1":164,"y1":127,"x2":1767,"y2":127,"stroke":78,"style":1768},[96,1777,1780],{"x":1778,"y":1779,"fill":100,"style":174},"370","129","SET NX lock — lost",[104,1782],{"x1":1728,"y1":106,"x2":1783,"y2":106,"stroke":78,"style":1768},"628",[96,1785,1787],{"x":1786,"y":121,"fill":100,"style":174},"360","A loads from source of truth",[104,1789],{"x1":1728,"y1":150,"x2":1767,"y2":150,"stroke":78,"style":1768},[96,1791,1793],{"x":1771,"y":1792,"fill":100,"style":174},"199","SET value, EX=ttl",[96,1795,1796],{"x":164,"y":107,"fill":78,"style":174},"B, C poll every 100 ms",[104,1798],{"x1":164,"y1":1799,"x2":1767,"y2":1799,"stroke":78,"style":1768},"262",[96,1801,1803],{"x":1778,"y":1802,"fill":100,"style":174},"256","GET key — HIT",[96,1805,1807],{"x":1786,"y":1806,"fill":78,"style":174},"292","one DB query served all three requests",[14,1809,1810],{},"The polling loop is deliberately simple. A busier service can swap the fixed 100 ms sleep for exponential backoff, but at the traffic levels where a single Redis lock matters, a tenth-of-a-second poll for a handful of iterations is plenty and keeps the code obvious.",[177,1812],{},[180,1814,1816],{"id":1815},"configuration-reference","Configuration reference",[1818,1819,1820,1836],"table",{},[1821,1822,1823],"thead",{},[1824,1825,1826,1830,1833],"tr",{},[1827,1828,1829],"th",{},"Env var",[1827,1831,1832],{},"Default",[1827,1834,1835],{},"Production recommendation",[1837,1838,1839,1858,1872,1886,1900],"tbody",{},[1824,1840,1841,1846,1851],{},[1842,1843,1844],"td",{},[208,1845,414],{},[1842,1847,1848],{},[208,1849,1850],{},"redis:\u002F\u002Flocalhost:6379\u002F0",[1842,1852,1853,1854,1857],{},"Managed Redis with TLS: ",[208,1855,1856],{},"rediss:\u002F\u002F...","; pin a dedicated DB index",[1824,1859,1860,1865,1869],{},[1842,1861,1862],{},[208,1863,1864],{},"CACHE_TTL_SECONDS",[1842,1866,1867],{},[208,1868,91],{},[1842,1870,1871],{},"30–300s for hot reads; tune per endpoint, not globally",[1824,1873,1874,1879,1883],{},[1842,1875,1876],{},[208,1877,1878],{},"CACHE_NAMESPACE",[1842,1880,1881],{},[208,1882,842],{},[1842,1884,1885],{},"Bump the version suffix to invalidate every key on deploy",[1824,1887,1888,1893,1897],{},[1842,1889,1890],{},[208,1891,1892],{},"CACHE_LOCK_TTL",[1842,1894,1895],{},[208,1896,70],{},[1842,1898,1899],{},"Slightly above your p99 loader latency so the lock never expires mid-rebuild",[1824,1901,1902,1907,1910],{},[1842,1903,1904],{},[208,1905,1906],{},"REDIS_MAX_CONNECTIONS",[1842,1908,1909],{},"pool default",[1842,1911,1912],{},"Cap near your worker count × concurrency to avoid exhausting Redis",[14,1914,1915,1916,1918,1919,1922,1923,1925],{},"Two of these deserve a second look. ",[208,1917,1892],{}," must sit ",[27,1920,1921],{},"above"," your slowest realistic loader run, or the lock expires while A is still rebuilding and B grabs it and starts a second rebuild — reintroducing the exact stampede you were preventing. Set it from real p99 numbers, not a guess. ",[208,1924,1906],{}," matters most on serverless or autoscaled hosts, where every replica opens its own pool; multiply pool size by replica count and confirm the total stays under your managed Redis plan's connection ceiling, since blowing past it fails requests rather than slowing them.",[177,1927],{},[180,1929,1931],{"id":1930},"measuring-hit-ratio-and-tuning-ttl","Measuring hit ratio and tuning TTL",[14,1933,1934,1935,221,1938,1941],{},"Everything above is theatre until you measure the one number that decides whether caching pays: the ",[188,1936,1937],{},"hit ratio",[208,1939,1940],{},"hits \u002F (hits + misses)",". It maps directly onto how much load reaches your database, and therefore onto your bill. The chart below plots, per 1,000 incoming requests, how many actually reach the database at different hit ratios.",[42,1943,51,1948,51,1951,51,1954,51,1957,51,1960,51,1965,51,1968,51,1971,51,1976,51,1980,51,1983,51,1988,51,1991,51,1995,51,1998,51,2001,51,2006,51,2010,51,2013,51,2018,51,2023,51,2026],{"viewBox":1944,"role":45,"ariaLabelledBy":1945,"xmlns":49,"style":50},"0 0 720 340",[1946,1947],"redis-cache-bar-t","redis-cache-bar-d",[53,1949,1950],{"id":1946},"Database load falls as hit ratio rises",[57,1952,1953],{"id":1947},"A bar chart of database queries per 1000 incoming requests. At 0 percent hit ratio all 1000 reach the database; at 50 percent 500; at 70 percent 300; at 90 percent 100; at 95 percent 50.",[80,1955],{"x":82,"y":82,"width":83,"height":1956,"fill":85},"340",[96,1958,1959],{"x":1786,"y":1723,"fill":100,"style":117},"Database queries per 1,000 requests, by cache hit ratio",[104,1961],{"x1":1962,"y1":1963,"x2":1964,"y2":1963,"stroke":1753,"style":1754},"90","285","690",[104,1966],{"x1":1962,"y1":1967,"x2":1962,"y2":1963,"stroke":1753,"style":1754},"45",[80,1969],{"x":1746,"y":1967,"width":91,"height":107,"rx":1970,"fill":155},"4",[96,1972,1975],{"x":1973,"y":1974,"fill":100,"style":122},"140","38","1000",[96,1977,1979],{"x":1973,"y":1978,"fill":78,"style":122},"304","0%",[80,1981],{"x":1982,"y":146,"width":91,"height":89,"rx":1970,"fill":155},"225",[96,1984,1987],{"x":1985,"y":1986,"fill":100,"style":122},"255","158","500",[96,1989,1990],{"x":1985,"y":1978,"fill":78,"style":122},"50%",[80,1992],{"x":1956,"y":1993,"width":91,"height":1994,"rx":1970,"fill":170},"213","72",[96,1996,84],{"x":1778,"y":1997,"fill":100,"style":122},"206",[96,1999,2000],{"x":1778,"y":1978,"fill":78,"style":122},"70%",[80,2002],{"x":2003,"y":2004,"width":91,"height":2005,"rx":1970,"fill":112},"455","261","24",[96,2007,1728],{"x":2008,"y":2009,"fill":100,"style":122},"485","254",[96,2011,2012],{"x":2008,"y":1978,"fill":78,"style":122},"90%",[80,2014],{"x":2015,"y":2016,"width":91,"height":2017,"rx":1970,"fill":112},"570","273","12",[96,2019,2022],{"x":2020,"y":2021,"fill":100,"style":122},"600","266","50",[96,2024,2025],{"x":2020,"y":1978,"fill":78,"style":122},"95%",[96,2027,2029],{"x":1786,"y":2028,"fill":78,"style":122},"328","cache hit ratio → fewer queries reach the database",[14,2031,2032,2033,2036],{},"The curve is steep where it counts. Moving from no cache to a 90% hit ratio cuts database load by a factor of ten — that is the jump from all 1,000 requests hitting Postgres to just 100. But the last stretch is expensive: squeezing 90% up to 95% only halves the ",[27,2034,2035],{},"remaining"," load, from 100 queries to 50, and usually costs you a much longer TTL and staler data to get there. Chase the first order of magnitude aggressively; treat the last few points as diminishing returns.",[14,2038,2039],{},"Instrument it with a pair of counters so the number is never a guess:",[255,2041,2043],{"className":376,"code":2042,"language":378,"meta":260,"style":260},"async def record(client: redis.Redis, hit: bool) -> None:\n    field = \"hits\" if hit else \"misses\"\n    await client.hincrby(f\"{NAMESPACE}:stats\", field, 1)\n",[208,2044,2045,2067,2086],{"__ignoreMap":260},[264,2046,2047,2049,2051,2054,2057,2059,2062,2065],{"class":104,"line":266},[264,2048,558],{"class":314},[264,2050,561],{"class":314},[264,2052,2053],{"class":269}," record",[264,2055,2056],{"class":318},"(client: redis.Redis, hit: ",[264,2058,975],{"class":413},[264,2060,2061],{"class":318},") -> ",[264,2063,2064],{"class":413},"None",[264,2066,730],{"class":318},[264,2068,2069,2072,2074,2077,2079,2081,2083],{"class":104,"line":328},[264,2070,2071],{"class":318},"    field ",[264,2073,322],{"class":314},[264,2075,2076],{"class":273}," \"hits\"",[264,2078,1255],{"class":314},[264,2080,1258],{"class":318},[264,2082,1261],{"class":314},[264,2084,2085],{"class":273}," \"misses\"\n",[264,2087,2088,2090,2093,2096,2098,2100,2103,2106,2109],{"class":104,"line":341},[264,2089,582],{"class":314},[264,2091,2092],{"class":318}," client.hincrby(",[264,2094,2095],{"class":314},"f",[264,2097,799],{"class":273},[264,2099,802],{"class":413},[264,2101,2102],{"class":273},":stats\"",[264,2104,2105],{"class":318},", field, ",[264,2107,2108],{"class":413},"1",[264,2110,431],{"class":318},[14,2112,2113,2114,2118,2119,2123],{},"Read those two counters on a schedule, compute the ratio, and ship it to your dashboards — the mechanics of that live in ",[18,2115,2117],{"href":2116},"\u002Fscaling-and-operating-production-python-apis\u002Fmonitoring-and-logging-python-apis\u002F","monitoring and logging Python APIs",", and attaching hit\u002Fmiss context to each request line is exactly what ",[18,2120,2122],{"href":2121},"\u002Fscaling-and-operating-production-python-apis\u002Fmonitoring-and-logging-python-apis\u002Fstructured-logging-with-structlog\u002F","structured logging with structlog"," is for. If the ratio sits below ~70%, your TTL is too short for the traffic pattern or your key cardinality is too high — you are minting a fresh key per request and never reusing one.",[177,2125],{},[180,2127,2129],{"id":2128},"gotchas-and-failure-modes","Gotchas and failure modes",[2131,2132,2133,2140,2158,2166,2172,2178],"ul",{},[2134,2135,2136,2139],"li",{},[188,2137,2138],{},"Caching authenticated responses."," Never cache a personalized payload under a shared key — user A will be served user B's data. Fold the user or tenant id into the cache key, or skip the cache entirely for private endpoints. This is a security bug, not a performance footgun.",[2134,2141,2142,2145,2146,2149,2150,2153,2154,2157],{},[188,2143,2144],{},"Serializing Pydantic models."," ",[208,2147,2148],{},"json.dumps()"," chokes on a Pydantic model. Call ",[208,2151,2152],{},"model.model_dump(mode=\"json\")"," before storing and rebuild with ",[208,2155,2156],{},"Model.model_validate(...)"," on read so dates and enums round-trip cleanly.",[2134,2159,2160,2163,2164,301],{},[188,2161,2162],{},"TTL vs invalidation."," A TTL bounds staleness but doesn't guarantee freshness — a value can be wrong for up to its full TTL. When correctness matters more than a few seconds of lag, you need active eviction; that trade-off is the whole subject of ",[18,2165,34],{"href":33},[2134,2167,2168,2171],{},[188,2169,2170],{},"Thundering herd on cold start."," After a deploy or a Redis flush, every key is empty and the lock from Step 4 is your only defense. Without it, a popular endpoint can take down your database in the first second of traffic.",[2134,2173,2174,2177],{},[188,2175,2176],{},"Caching errors."," If your loader raises, don't write a placeholder into Redis. Let the exception propagate so the next request retries instead of serving a cached failure for the full TTL.",[2134,2179,2180,2183,2184,2187,2188,2191],{},[188,2181,2182],{},"Unbounded memory."," Every key without a TTL is a permanent tenant of your Redis instance. Set ",[208,2185,2186],{},"maxmemory"," and an eviction policy (",[208,2189,2190],{},"allkeys-lru",") on the server so a bug that writes non-expiring keys degrades gracefully instead of OOM-killing Redis.",[177,2193],{},[180,2195,2197],{"id":2196},"verification","Verification",[14,2199,2200,2201,843,2203,373],{},"Start the app, hit the endpoint twice, and watch the header flip from ",[208,2202,151],{},[208,2204,133],{},[255,2206,2208],{"className":257,"code":2207,"language":259,"meta":260,"style":260},"curl -i http:\u002F\u002Flocalhost:8000\u002Fproducts\u002F42 | grep -i x-cache\n# X-Cache: MISS\ncurl -i http:\u002F\u002Flocalhost:8000\u002Fproducts\u002F42 | grep -i x-cache\n# X-Cache: HIT\n",[208,2209,2210,2232,2237,2253],{"__ignoreMap":260},[264,2211,2212,2215,2218,2221,2224,2227,2229],{"class":104,"line":266},[264,2213,2214],{"class":269},"curl",[264,2216,2217],{"class":413}," -i",[264,2219,2220],{"class":273}," http:\u002F\u002Flocalhost:8000\u002Fproducts\u002F42",[264,2222,2223],{"class":314}," |",[264,2225,2226],{"class":269}," grep",[264,2228,2217],{"class":413},[264,2230,2231],{"class":273}," x-cache\n",[264,2233,2234],{"class":104,"line":328},[264,2235,2236],{"class":1210},"# X-Cache: MISS\n",[264,2238,2239,2241,2243,2245,2247,2249,2251],{"class":104,"line":341},[264,2240,2214],{"class":269},[264,2242,2217],{"class":413},[264,2244,2220],{"class":273},[264,2246,2223],{"class":314},[264,2248,2226],{"class":269},[264,2250,2217],{"class":413},[264,2252,2231],{"class":273},[264,2254,2255],{"class":104,"line":410},[264,2256,2257],{"class":1210},"# X-Cache: HIT\n",[14,2259,2260],{},"Confirm the key landed in Redis and inspect its remaining TTL:",[255,2262,2264],{"className":257,"code":2263,"language":259,"meta":260,"style":260},"redis-cli KEYS 'api:v1:product:*'\n# 1) \"api:v1:product:9f4c1a2b3d5e6f70\"\nredis-cli TTL api:v1:product:9f4c1a2b3d5e6f70\n# (integer) 58\n",[208,2265,2266,2277,2282,2292],{"__ignoreMap":260},[264,2267,2268,2271,2274],{"class":104,"line":266},[264,2269,2270],{"class":269},"redis-cli",[264,2272,2273],{"class":273}," KEYS",[264,2275,2276],{"class":273}," 'api:v1:product:*'\n",[264,2278,2279],{"class":104,"line":328},[264,2280,2281],{"class":1210},"# 1) \"api:v1:product:9f4c1a2b3d5e6f70\"\n",[264,2283,2284,2286,2289],{"class":104,"line":341},[264,2285,2270],{"class":269},[264,2287,2288],{"class":273}," TTL",[264,2290,2291],{"class":273}," api:v1:product:9f4c1a2b3d5e6f70\n",[264,2293,2294],{"class":104,"line":410},[264,2295,2296],{"class":1210},"# (integer) 58\n",[14,2298,2299,2300,2303,2304,2307,2308,2311,2312,2314,2315,843,2317,2319,2320,301],{},"A live ",[208,2301,2302],{},"TTL"," counting down confirms the write succeeded with expiry. If you see ",[208,2305,2306],{},"-1",", you stored the key without ",[208,2309,2310],{},"ex="," and it will never expire — a silent memory leak. Bake this into your test suite rather than checking it by hand: an integration test that calls the endpoint twice and asserts ",[208,2313,1277],{}," flips from ",[208,2316,151],{},[208,2318,133],{},", then asserts a non-negative TTL on the key, catches a broken cache before it ships. Wiring that against a real Redis is covered in ",[18,2321,2323],{"href":2322},"\u002Fscaling-and-operating-production-python-apis\u002Ftesting-python-apis-with-pytest\u002F","testing Python APIs with pytest",[177,2325],{},[180,2327,2329],{"id":2328},"cost-and-performance-note","Cost and performance note",[14,2331,2332],{},"Caching trades a little Redis memory for a lot of saved compute. The lever is your hit ratio: at a 90% hit ratio, nine of ten requests skip the database and return from memory in sub-millisecond time, so your upstream sees a tenth of the load. For a managed Postgres or a metered upstream API, that's a direct, linear reduction in your most expensive resource — often the difference between staying on a small instance and paying for a bigger one.",[14,2334,2335,2336,2340,2341,301],{},"Put real numbers on it. Suppose an endpoint serves 5 million requests a month and each uncached response costs a 15 ms database query plus its share of a $50\u002Fmonth Postgres plan running near capacity. At a 90% hit ratio you drop from 5 million queries to 500,000, which typically lets that same plan coast at 10% utilization — you defer the upgrade to the next tier indefinitely. When the loader is a paid third-party API billed per call, the saving is even starker: caching an upstream that charges $0.50 per thousand calls at a 90% hit ratio turns a $2,500 monthly bill into $250. That is why caching is also a pricing lever — it lets you offer cheaper, higher-limit tiers without proportional infrastructure cost, and the same discipline is what keeps ",[18,2337,2339],{"href":2338},"\u002Fautomating-side-hustle-operations-with-apis\u002Fautomating-ai-workflows-with-python-apis\u002Fcontrolling-llm-api-costs-in-production\u002F","LLM API costs under control in production"," when responses are expensive to generate. Turning that efficiency into margin is the subject of ",[18,2342,2344],{"href":2343},"\u002Fbuilding-monetizing-api-driven-micro-saas\u002Fdesigning-api-pricing-tiers\u002F","designing API pricing tiers",[177,2346],{},[180,2348,2350],{"id":2349},"faq","FAQ",[14,2352,2353,2356,2357,2359,2360,2363],{},[188,2354,2355],{},"Should I cache POST or write requests?","\nNo. Cache only safe, idempotent reads — ",[208,2358,210],{}," requests with no side effects. Writes must hit your source of truth every time, and they're what ",[27,2361,2362],{},"invalidate"," cached reads. Caching a write would serve a customer a stale confirmation of a change that never happened.",[14,2365,2366,2369],{},[188,2367,2368],{},"How do I pick a TTL?","\nStart from how stale the data is allowed to be, not from a round number. Pricing or inventory that changes hourly tolerates a 60–300s TTL; a slowly-changing reference list can sit at an hour or more. Shorter TTLs lower staleness but raise miss rate and upstream load, so the TTL is really a dial between your compute bill and your freshness guarantee — set it per endpoint, never globally.",[14,2371,2372,2375,2378,2379,301],{},[188,2373,2374],{},"Why use Redis instead of in-process caching like lru_cache?",[208,2376,2377],{},"lru_cache"," lives in one worker's memory, so every worker and every replica caches independently and you get inconsistent results plus N times the upstream load. Redis is shared across all workers and survives restarts, which is what you need in production. The full trade-off, including when a local cache genuinely wins on latency, is in ",[18,2380,39],{"href":38},[14,2382,2383,2386],{},[188,2384,2385],{},"What does this cost to run at scale, and what's the migration risk?","\nA managed Redis with 256 MB is a few dollars a month and holds hundreds of thousands of small JSON entries — trivial next to the database and compute it saves. The migration risk is low because cache-aside is additive: you wrap existing reads, and if Redis disappears you fall through to the loader. The one hazard is a coarse cache key leaking data across tenants, so audit keys before you cache anything user-specific.",[14,2388,2389,2392,2393,2395],{},[188,2390,2391],{},"Does decode_responses hurt performance?","\nNegligibly. It decodes bytes to ",[208,2394,368],{}," for you so your JSON handling is clean. If you cache binary blobs or want to store compressed payloads, drop it and decode selectively instead.",[177,2397],{},[180,2399,2401],{"id":2400},"related","Related",[14,2403,2404],{},[188,2405,2406],{},"Same track:",[2131,2408,2409,2415,2421,2427],{},[2134,2410,2411,2414],{},[18,2412,2413],{"href":33},"Cache Invalidation Strategies"," — how to evict deliberately once a TTL is not fresh enough.",[2134,2416,2417,2420],{},[18,2418,2419],{"href":38},"Redis vs In-Memory Caching for FastAPI"," — pick the right cache tier before you build.",[2134,2422,2423,2426],{},[18,2424,2425],{"href":2116},"Monitoring and Logging Python APIs"," — track the hit ratio that decides if caching pays.",[2134,2428,2429,2431],{},[18,2430,21],{"href":20}," — the wider operations guide this sits in.",[14,2433,2434],{},[188,2435,2436],{},"Other tracks:",[2131,2438,2439,2445],{},[2134,2440,2441,2444],{},[18,2442,2443],{"href":289},"Setting Up FastAPI"," — the app this cache plugs into.",[2134,2446,2447,2450],{},[18,2448,2449],{"href":2343},"Designing API Pricing Tiers"," — turn saved compute into margin.",[2452,2453,2454],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":260,"searchDepth":328,"depth":328,"links":2456},[2457,2458,2459,2460,2461,2462,2463,2464,2465,2466,2467,2468,2469],{"id":182,"depth":328,"text":183},{"id":237,"depth":328,"text":238},{"id":356,"depth":328,"text":357},{"id":645,"depth":328,"text":646},{"id":854,"depth":328,"text":855},{"id":1298,"depth":328,"text":1299},{"id":1815,"depth":328,"text":1816},{"id":1930,"depth":328,"text":1931},{"id":2128,"depth":328,"text":2129},{"id":2196,"depth":328,"text":2197},{"id":2328,"depth":328,"text":2329},{"id":2349,"depth":328,"text":2350},{"id":2400,"depth":328,"text":2401},"Cache Python API responses with Redis using redis-py async, FastAPI dependencies, cache-aside, TTLs, and stampede protection to cut latency and compute cost.","md",{"pageTitle":2473,"type":2474,"datePublished":2475,"dateModified":2476},"Cache Python API Responses with Redis: A Builder's Guide","guide","2026-06-18","2026-07-23","\u002Fscaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis",{"title":5,"description":2470},"scaling-and-operating-production-python-apis\u002Fcaching-python-api-responses-with-redis\u002Findex","ln-WVA-BNAaix7rRydWOYcIjTQghykmXaJ-t7-Fsgns",{"@context":2482,"@type":2483,"mainEntity":2484},"https:\u002F\u002Fschema.org","FAQPage",[2485,2490,2493,2496,2499],{"@type":2486,"name":2355,"acceptedAnswer":2487},"Question",{"@type":2488,"text":2489},"Answer","No. Cache only safe, idempotent reads — GET requests with no side effects. Writes must hit your source of truth every time, and they're what invalidate cached reads. Caching a write would serve a customer a stale confirmation of a change that never happened.",{"@type":2486,"name":2368,"acceptedAnswer":2491},{"@type":2488,"text":2492},"Start from how stale the data is allowed to be, not from a round number. Pricing or inventory that changes hourly tolerates a 60–300s TTL; a slowly-changing reference list can sit at an hour or more. Shorter TTLs lower staleness but raise miss rate and upstream load, so the TTL is really a dial between your compute bill and your freshness guarantee — set it per endpoint, never globally.",{"@type":2486,"name":2374,"acceptedAnswer":2494},{"@type":2488,"text":2495},"lru_cache lives in one worker's memory, so every worker and every replica caches independently and you get inconsistent results plus N times the upstream load. Redis is shared across all workers and survives restarts, which is what you need in production. The full trade-off, including when a local cache genuinely wins on latency, is in Redis vs in-memory caching for FastAPI.",{"@type":2486,"name":2385,"acceptedAnswer":2497},{"@type":2488,"text":2498},"A managed Redis with 256 MB is a few dollars a month and holds hundreds of thousands of small JSON entries — trivial next to the database and compute it saves. The migration risk is low because cache-aside is additive: you wrap existing reads, and if Redis disappears you fall through to the loader. The one hazard is a coarse cache key leaking data across tenants, so audit keys before you cache anything user-specific.",{"@type":2486,"name":2391,"acceptedAnswer":2500},{"@type":2488,"text":2501},"Negligibly. It decodes bytes to str for you so your JSON handling is clean. If you cache binary blobs or want to store compressed payloads, drop it and decode selectively instead. ---",1784887027769]