Every repeat query you send to a SERP API costs a credit and returns roughly the same organic results as it did an hour ago. Most of that traffic is your own doing: a dashboard refresh, a re-run of the same report, a test that reads the same keyword. Cache the response and the second call is free — same JSON, no request, no credit.
You don't need Redis for this. A folder of JSON files and a 30-line module will carry a small pipeline for months.
The cache key is the whole request
Two calls that differ only in gl are different answers. Build the key from every parameter you send, in a fixed order:
// serp-cache.mjs — disk cache in front of a SERP API
import { createHash } from "node:crypto";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import path from "node:path";
const API_URL = "https://api.serpbase.dev/google/search";
const CACHE_DIR = ".serp-cache";
const TTL_MS = 6 * 60 * 60 * 1000; // 6 hours; set to Infinity for stable snapshots
function cacheKey(params) {
const canon = Object.keys(params) // only the params that shape results
.filter((k) => params[k] !== undefined)
.sort()
.map((k) => `${k}=${params[k]}`)
.join("&");
return createHash("sha1").update(canon).digest("hex").slice(0, 20);
}
export async function search(params, apiKey = process.env.SERPBASE_API_KEY) {
const file = path.join(CACHE_DIR, `${cacheKey(params)}.json`);
try {
const hit = JSON.parse(await readFile(file, "utf8"));
if (Date.now() - hit.at < TTL_MS) return hit.data;
} catch {
/* miss: fall through to the network */
}
const resp = await fetch(API_URL, {
method: "POST",
headers: { "X-API-Key": apiKey, "Content-Type": "application/json" },
body: JSON.stringify(params),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (data.status !== 0) throw new Error(`API error: ${data.error}`);
await mkdir(CACHE_DIR, { recursive: true });
await writeFile(file, JSON.stringify({ at: Date.now(), params, data }));
return data;
}
Params, billing, and the response envelope are defined in the SerpBase API docs — the wrapper above adds nothing to the request, it just replays the last answer while it's still fresh.
What one afternoon of caching saves
Say you track 20 keywords and reload a dashboard 5 times a day:
| Usage pattern | Requests/day | With 6h TTL | Credits saved |
|---|---|---|---|
| Dashboard, reloaded often | 100 | ~20 | 80% |
| Weekly report re-runs | 20 | 20 (first run) then 0 | ~100% after Monday |
| Rank tracking, once daily | 20 | 20 | 0 — don't cache this |
The last row matters: if the number you care about is the position right now, a cache is lying to you. Rank tracking wants fresh rank fields every run. Caching is for everything that reads results more often than results change.
Delete like you mean it
A cache is stale data wearing a friendly name. Two habits keep it honest:
-
Invalidate on purpose. Re-run with
TTL_MS = 0when you upgrade parsing code or want a clean snapshot — the module re-records every key once. -
Ship the cache folder in CI artifacts, not in git. Fixtures of real responses are gold for tests; a 40 MB
.serp-cachein your repo is not.
What it costs anyway
Standard searches are 1 credit per successful request; packs start around $0.50/1k with volume tiers down to $0.30/1k, the $3/month Starter Boost includes 10,000 searches, and new accounts get 100 free searches — enough to fill this cache several times over and verify the wrapper end to end. Exact numbers live on the pricing page; if they've moved, the math above still holds: a hit rate of X% cuts your bill by X%.
FAQ
Why disk files instead of SQLite or Redis? One JSON per query greps well, diffs well, and survives process restarts. Move to SQLite when you want queries across cached responses (that's a data problem, not a caching problem); move to Redis when latency matters more than simplicity.
Should the TTL differ per endpoint? Yes. News and video results go stale in hours; a gl-pinned organic snapshot can sit for days if you only study structure. Cache people_also_ask and related_searches aggressively — those modules move slowly.
Does the cache hide API errors? It shouldn't — the code above only writes on status === 0. If a request fails, nothing is cached and the error propagates.
Start with the 20-line version, watch the hit rate for a week, and only then decide whether you need something fancier.
Top comments (0)