Local SEO

Local SEO response cache

A repeat request inside the freshness window is free and the answer is marked cached; how long each endpoint keeps its answer, and how to force a fresh result.

Updated · 5 min read

Most Local SEO actions call the same provider (localseodata.com) the customer's request reaches. When a second account asks for the search volume of "plumber canton ohio" the same week, the answer is the same. The response cache keeps it, so the second account is served from storage, the provider is not called, and the second account is not charged.

What is cached is the provider's raw envelope, not our mapped result. The next read runs it through the same parser, so a provider shape change or a mapper fix never serves a stale shape.

Where to see it

The response is what tells you. Two fields on every cached run:

  • cached: true — every provider call inside the run was a hit. Nothing was bought.
  • cached_at: "<ISO timestamp>" — when the entry was stored; this is the oldest stored_at in the run, so it is the honest age of the answer.

A run that mixed a hit with a live call bought something (one provider call did not have an entry, or the entry failed the parser, or the entry was empty). The response says cached: false and settles at its quote like any other run.

A run that returned nothing usable (a SERP with no business, a review list with no reviews) is settled uncharged and is not stored as a cacheable entry. Caching an empty answer would turn "no data, not charged" into "no data, for a week". A subsequent request for the same answer is also settled uncharged and may go live.

How long each entry lasts

The TTL depends on what the answer measures (src/localSeoDataCachePolicy.ts):

Group Endpoints TTL Scope
Keywords Search volume, related phrases, trends, opportunities, keywords for a site 7 days Global
Backlinks & authority Backlink gap and summary, brand mentions, local authority score 3 days Global
SERPs Local pack, organic, AI Mode, Local Finder, Maps 12 hours Global
AI visibility, top pages, sources, mentions, compare, AI scraper, AI live LLM response 1 day Global / user
Citations, audits, business profile and reviews, competitor gap 1 day User
Locations search and coverage (free, served from a D1 gazetteer) 30 days Global
Map scans (5×5, 7×7, 9×9) Never —

Global entries are public market data: a keyword's search volume is the same whoever asks. User entries are scoped to the account, keyed by user id, and deleted when the account is. Map scans are never cached because the answer is a measurement of now (a time series of grids).

The TTL is applied when an entry is READ, against the time it was stored. Shortening one takes effect on the next request without any data being touched.

What scope means

Two requests for the same answer, from two different accounts, hit the same global entry when there is one. Two requests from the same account for the same thing hit the same user entry; one account cannot read what another wrote, even when the request body is identical.

Per-account entries cover requests with text the customer typed about their own business or wrote themselves (a business name with its street address and phone, free-text prompts, the business profile and review lookups). Most of that is public too, but the request body is the customer's context, and sharing an entry would let one account learn what another asked about.

How to force a fresh result

Two ways, both end up at the same code path (runLocalSeo in routes/localseo-extended.ts):

  1. Add refresh: true to the request body. The legacy reports endpoint takes this field.
  2. Add ?refresh=true to the URL.

Either one skips the lookup and runs the request live. The fresh answer is written back, so the next request is a hit again. You pay the quote.

The quote is unchanged by refresh. A live call costs the same as a first call. The only thing refresh does is skip the cache lookup. The cache hit that was avoided is counted separately (localseodata_cache_stats.refreshes).

What the cache is not

  • It is not the rank history or the map scan time series. Those are kept in D1 and refreshed only when the action runs.
  • It is not the business profile or the saved keyword targets. Those are account settings, not provider responses.
  • It is not a credit pack. A cache hit is a provider call the platform did not make; the customer's credit allowance is for actions they take, not for answers the cache served. The provider COGS ledger (localseodata_usage) does not see cache hits; the cache writes its own counter (localseodata_cache_stats) instead.

Storage and lifecycle

The cache lives in the ARTIFACTS R2 bucket, under the prefix localseo-cache/v1/. One object per entry: { v, endpoint, stored_at, provider_credits, payload }. R2 rather than D1 because entries are whole provider envelopes (a keyword list or an audit runs to tens of KB), they are read by exact key and never queried, and the TTL is soft — an expired object is simply ignored and overwritten by the next live call. A D1 table would have needed a sweep to stay bounded.

The bucket wants a lifecycle rule on the prefix (the operations docs note this) so objects nobody re-requests do not sit there forever. The binding is optional: without it every lookup is a miss and every write is skipped, and the request runs live.

When the account goes, the per-account prefix is deleted; shared market-data entries hold nothing of theirs and stay.

Common questions

My answer is wrong and I want to re-fetch. Pass refresh: true (body or ?refresh=true). The next run is live and the entry is replaced.

How do I know if I got a cache hit? cached: true and cached_at are in the response body. cached_at is the oldest entry's storage time, so it is the honest age of the answer; a long cached_at is fine when the answer has not moved.

The cache showed me stale data. A cache hit returns whatever was stored, on or before its TTL. If you see data older than the TTL, the read path failed to see the TTL or the entry is in an odd state. Send a refresh and the next read is the new entry.

Still stuck?

Ask the people who run the scanner.

Send the domain and what you expected to see. We look at the same scan you are looking at and write back with what it means and what to change.