DEV Community

Dietly
Dietly

Posted on • Originally published at getdietly.com

Caching Strategies for a Nutrition API: TTLs, Keys and the Cost Math

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.

The short version: cache barcode and food-id lookups for 30 days,
search results for a day, and never cache a 429 or a 5xx. On a food-logging app that alone typically
removes 80 to 95% of outbound calls, because users log the same foods repeatedly.

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)