Kookee content changes when a person publishes something — a few times a day at most for most projects. Your application probably serves pages far more often than that.
Closing that gap is the single highest-leverage thing you can do for both cost and speed.
Fetch on the server first
Before any cache layer, get the fetch off the client.
A client-side fetch makes one API request per visitor. A server-rendered page makes one request that every visitor to that render shares. On the Free plan's 100,000 monthly requests, shared by every project the account owns, that difference decides whether a modest launch fits inside the plan.
Time-based caching
For most content pages, a few minutes of staleness is invisible to readers and eliminates nearly all API traffic.
Next.js:
export const revalidate = 300; // 5 minutes
Nuxt:
export default defineCachedEventHandler(handler, { maxAge: 300 });
Plain Node:
const cache = new Map<string, { value: unknown; expires: number }>();
async function cached<T>(key: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> {
const hit = cache.get(key);
if (hit && hit.expires > Date.now()) return hit.value as T;
const value = await fetcher();
cache.set(key, { value, expires: Date.now() + ttlMs });
return value;
}
const posts = await cached('blog:list', 300_000, () => kookee.blog.list({ limit: 10 }));
An in-process map is per-instance. Across several server instances you get one fetch each, which is usually fine — reach for Redis only when it is not.
Choosing a TTL
Match it to how quickly a change needs to be visible:
Config and feature flags — 30 to 60 seconds. These are the values you change when something is wrong, and a five-minute wait on a kill switch is painful.
Announcements — 1 to 5 minutes. A maintenance notice should not take an hour to appear.
Blog, help articles, pages — 5 to 15 minutes, or on-demand revalidation.
Changelog — 5 to 15 minutes.
When a publish must be visible immediately, use on-demand revalidation rather than a very short TTL. A one-second cache is nearly no cache at all.
Stale-while-revalidate
The pattern worth knowing: serve the cached copy instantly, refresh in the background.
async function staleWhileRevalidate<T>(key: string, ttlMs: number, fetcher: () => Promise<T>) {
const hit = cache.get(key);
if (hit) {
if (hit.expires <= Date.now()) {
// refresh in the background; do not await
fetcher()
.then((value) => cache.set(key, { value, expires: Date.now() + ttlMs }))
.catch(() => {});
}
return hit.value as T;
}
const value = await fetcher();
cache.set(key, { value, expires: Date.now() + ttlMs });
return value;
}
Nobody waits on the network, and a brief API outage is invisible as long as something is cached. Swallowing the background error is deliberate — a failed refresh should not break a request that already has a good answer.
Cache the negative cases too
A 404 that is not cached means every request for a bad URL hits the API. Crawlers and scanners will find those URLs.
Cache "not found" for a shorter time than real content — 30 to 60 seconds is enough to absorb a flood without making a genuinely new page wait.
What not to cache
Mutations — votes, reactions, feedback posts. These are writes.
Anything per-user. Content is public and shared, so it caches globally. Dismissed announcements are the case that tempts people to break this: cache the raw list of active announcements once, and filter it against each user's dismissed set at render time. Caching the already-filtered list gives one user's dismissals to everybody.
Warming after a publish
If you use on-demand revalidation, the first request after a purge pays the full latency. For high-traffic pages, re-request the page yourself right after revalidating so the cache is warm before real traffic arrives.
Measuring
Your API usage is visible in account settings. If it is higher than the number of publishes times the number of pages, something is fetching more than it needs — usually a client-side call that should be server-side, or a config read inside a component that renders many times.