Caching strategies for nutrition APIs
Nutrition data is close to the ideal caching workload: the records almost never change, the same few thousand foods account for most lookups, and a stale answer is rarely wrong. A cache in front of DietlyAPI is therefore not an optimisation you add later under load. It is the difference between an app that fits comfortably inside the free 30 requests per minute and one that needs a paid plan to do the same job. This guide covers what to cache, for how long, how to key it, and the arithmetic that tells you which plan you actually need.
Pick a TTL per data type, not one for everything
The right expiry follows from how often the underlying thing changes and what a stale answer costs you.
| Response | Suggested TTL | Why |
|---|---|---|
GET /barcode/{code} hit | 30 days | A barcode maps to one product; reformulations are rare and slow |
GET /food/{id} | 30 days | Same record, addressed directly; the safest thing in the catalog to cache |
GET /search?q=... | 1 to 7 days | Ranking and catalog coverage improve over time, so let it refresh |
| 404 from a lookup | 1 to 24 hours | Negative caching stops a scanner hammering the same unknown barcode |
| 429 or any 5xx | Never | Caching a failure turns a blip into an outage you inflicted |
Negative caching deserves the emphasis. A barcode scanner pointed at an unlisted product will retry on every frame if you let it, and each one of those is a real request against your limit. Caching the 404 for even an hour converts a pathological loop into a single call, and the user experience is identical because the answer is the same either way.
Key the cache on what actually varies
A cache key should contain everything that changes the response and nothing that does not. For this API that is the endpoint, the identifier or normalised query, and the limit.
def cache_key(kind, value, limit=None):
if kind == "search":
# normalise so "Greek Yogurt", "greek yogurt " and "greek yogurt"
# are one entry rather than three
value = " ".join(value.lower().split())
return f"dietly:{kind}:{value}" + (f":{limit}" if limit else "")
cache_key("search", " Greek Yogurt ", 10) # dietly:search:greek yogurt:10
cache_key("barcode", "5000159407236") # dietly:barcode:5000159407236
Two mistakes are worth avoiding. Do not put the API key in the cache key: the response does not
depend on which key fetched it, and including it fragments the cache per user for no benefit. Do put
the limit in, because a request for 5 results and one for 50 genuinely differ, and serving
the short list to the caller who asked for the long one is a bug that will take you an afternoon to
find.
A cache that fits in twenty lines
You do not need Redis to start. A dictionary with timestamps handles a single process, and the interface is the same one you will keep when you move to something shared.
import time
import requests
class CachedDietly:
def __init__(self, ttl=60 * 60 * 24 * 30, api_key=None):
self.ttl = ttl
self.store = {}
self.session = requests.Session()
if api_key:
self.session.headers["Authorization"] = f"Bearer {api_key}"
def _get(self, key, url, params=None, ttl=None):
ttl = self.ttl if ttl is None else ttl
hit = self.store.get(key)
if hit and time.time() - hit[0] < ttl:
return hit[1]
r = self.session.get(url, params=params, timeout=10)
if r.status_code == 404:
self.store[key] = (time.time(), None) # negative cache
return None
r.raise_for_status() # never cache 429/5xx
data = r.json() # parse once, not twice
self.store[key] = (time.time(), data)
return data
def barcode(self, code):
return self._get(f"barcode:{code}",
f"https://api.getdietly.com/barcode/{code}")
def search(self, q, limit=10):
q = " ".join(q.lower().split())
return self._get(f"search:{q}:{limit}",
"https://api.getdietly.com/search",
{"q": q, "limit": limit},
ttl=60 * 60 * 24)
The important line is r.raise_for_status() sitting above the store write.
Failures must never enter the cache. The second important line is the negative cache on 404, which
stores None rather than skipping the write, so a repeated miss costs nothing.
Move it out of process when you have more than one
An in-process dictionary dies with the worker and is duplicated across every replica, so four workers means four cold caches and four times the upstream traffic. Redis with a TTL fixes both.
import json
import redis
r = redis.Redis()
def cached_json(key, ttl, fetch):
raw = r.get(key)
if raw is not None:
return json.loads(raw)
value = fetch()
r.setex(key, ttl, json.dumps(value))
return value
food = cached_json("dietly:barcode:5000159407236", 60 * 60 * 24 * 30,
lambda: requests.get(
"https://api.getdietly.com/barcode/5000159407236",
timeout=10).json())
If your app is a web front end, the browser can carry part of the load too. Barcode and food-id responses are safe to cache in the client for a day, and doing so removes the network round trip entirely for a user scrolling back through their own food log. Search is the one to leave uncached in the browser, because a stale result list is more visible than a stale nutrient panel.
Do not put the key in the browser
Caching pushes people toward calling the API from client code, which is where keys leak. Anything shipped to a browser or a mobile binary is public, including values in environment variables at build time. Call the API from your own server, cache there, and let your front end talk to your endpoint. The public read endpoints work without a key, so a prototype can genuinely run key-free from the browser at 30 requests per minute per IP, and that is a reasonable way to build a demo. Ship it with a key in it and you have published the key. The key hygiene guide covers rotation if that has already happened.
The arithmetic that decides your plan
Work in requests per minute at peak, not per month, because the limit is per minute. Suppose a food-logging app with 2,000 daily active users, each logging six items, and a peak hour carrying a fifth of the day's traffic. That is 12,000 lookups a day, 2,400 in the peak hour, or 40 per minute: over the 30 per minute free ceiling, so uncached you need a paid plan. Now add a cache. Food logging is extremely repetitive, so a 90% hit rate is conservative once the cache is warm, and 40 per minute becomes 4 per minute. The same app fits in the free tier with room to spare, and every cached response is also faster than the fastest possible network call.
That is the honest version of the advice, so here is the equally honest limit of it. Caching does not
help a workload whose keys are nearly all unique, such as a one-off bulk import of a product catalog
you have never seen before. For that case, pace the import against X-RateLimit-Remaining,
run it once, and store the results in your own database rather than repeatedly asking. If you need
millions of rows, ask about a bulk export instead of crawling the API for a week; that is a cheaper
conversation for everyone.
Measure the hit rate, or you are guessing
Count hits and misses and log the ratio. A hit rate below 50% on a consumer food app almost always means the cache key is wrong, usually because an unnormalised search string, a per-user prefix or a timestamp has crept into it. A hit rate above 95% means you can extend TTLs further and stop thinking about the rate limit at all. Both numbers are cheap to collect and neither is guessable from first principles.
Common questions
How long should I cache nutrition API responses?
Barcode and food-id lookups for around 30 days, since a barcode maps to one product and reformulations are rare. Search results for one to seven days so improved ranking and coverage reach your users. Never cache a 429 or a 5xx.
Should I cache 404 responses?
Yes, for one to twenty-four hours. A barcode scanner pointed at an unlisted product will otherwise retry on every frame, and each retry is a real request against your rate limit for an answer that will not change.
Can caching keep me on the free tier?
Often. A food-logging app doing 40 lookups per minute at peak exceeds the 30 per minute free limit, but food logging is repetitive enough that a 90% cache hit rate is conservative, which brings it to about 4 per minute.
Can I call the API directly from the browser?
Only without a key. The public read endpoints work anonymously at 30 requests per minute per IP, which is fine for a prototype. Anything shipped to a browser or a mobile binary is public, so a key in client code is a published key.