Enterprise rank tracking: millions of keywords, reliably
Enterprise rank tracking is a scheduling and data-engineering problem more than a scraping one. At hundreds of thousands to millions of keywords, the questions become: how do you spread collection so it finishes on time, how do you avoid paying twice for the same SERP, how do you keep a queue flowing without overflowing it, where do billions of rows live, and how do you know a run is correct before customers see it. The sections below answer each on this API’s async tasks, with formulas to size your own system from your own measurements.
The shape of the workload
Write the matrix down first:
checks per month = keywords × markets × runs per month
Every multiplier is a product decision. A platform with 400,000 customer keywords, an average of 3 markets, checked daily, needs 400,000 × 3 × 30 = 36 million checks a month. City and device targeting on Google Search are each one more multiplier. The same platform checking the long tail weekly might need a third of that. The biggest savings come before any engineering.
Step 1: tier the schedule
Not every keyword deserves a daily check.
| Tier | Typical members | Frequency | Depth |
|---|---|---|---|
| Hot | Revenue keywords, active campaigns, keywords that moved last run | Daily | Page 1, AI Overview on |
| Core | Tracked keywords with traffic | 2–3 times a week | Page 1 |
| Tail | Long-tail and discovery keywords | Weekly | Page 1 |
| Deep | Competitive research needing positions 11–100 | Monthly | Up to 10 pages |
Promote and demote automatically: a tail keyword whose position changed by more than a threshold moves to hot for a week. This keeps coverage where change happens.
Spread runs over the day and week
Do not submit a week’s work at midnight on Monday. Assign each keyword a stable slot:
import hashlib
def slot(keyword_id: str, slots: int) -> int:
return int(hashlib.sha256(keyword_id.encode()).hexdigest(), 16) % slots
# weekly tier: 7 day-slots; daily tier: 24 hour-slots
day = slot("kw-839201", 7)
hour = slot("kw-839201", 24)
A stable hash keeps each keyword on the same day and hour, so its history has even spacing, and load is flat.
Step 2: collect each SERP once
Platforms with many customers track the same queries. A collection key identifies a unique SERP observation:
(normalized query, country, pages, include flags, date)
Normalize the query (trim, collapse whitespace, lowercase if your product treats case as equal) and deduplicate on the collection key before submission. One task serves every project that tracks that key; results are fanned out by join, not by extra requests.
-- tracked keywords across all tenants for today's slot
select md5(concat_ws('|', lower(trim(query)), country,
pages::text, include_aio::text, current_date::text)) as collection_key,
min(query) as query, country, pages, include_aio,
array_agg(project_keyword_id) as subscribers
from tracked_keywords
where schedule_day = extract(isodow from current_date) - 1
group by 1, 3, 4, 5;
The collection key doubles as the basis of the idempotency key.
Step 3: submit through a queue feeder
The limits that shape submission
From the async and rate limits docs:
POST /v1/async/task/batchtakes 1 to 500 tasks per request, each admitted independently.- There is no per-account request rate limit.
X-RateLimit-LimitandX-RateLimit-Remainingare deprecated and always 100000. - Async submissions are checked against queue capacity, which only the memory of queued and running tasks bounds: about 100,000 tasks of a typical prompt, more of shorter ones. A batch that would overflow it is rejected whole with
429 QUEUE_LIMIT_EXCEEDED, whosedetailsincludequeuedCount,batchSize,maxQueueSizeandremainingCapacity. - There is no concurrency cap; queued tasks run in priority order.
- A task has 4 hours from creation to start, else it fails uncharged with
QUEUE_WAIT_EXCEEDED. priority(1 to 10, default 1) orders your own queue; higher runs first, equal priorities in submission order.
Feed, don’t dump
A feeder keeps the queue at a working depth and never overflows it:
import os
import time
import requests
API = "https://api.answerline.dev"
S = requests.Session()
S.headers["Authorization"] = f"Bearer {os.environ['ANSWERLINE_API_KEY']}"
TARGET_QUEUED = 20_000 # your choice: enough buffer, far below capacity
def queued() -> int:
return S.get(f"{API}/v1/async/status", timeout=30).json()["queuedTasks"]
def feed(pending: "Iterator[list[dict]]", record) -> None:
for batch in pending: # batches of <= 500 tasks, highest priority first
while queued() > TARGET_QUEUED:
time.sleep(15)
resp = S.post(f"{API}/v1/async/task/batch", json=batch, timeout=60)
if resp.status_code == 429:
wait = int(resp.headers.get("Retry-After", 5))
time.sleep(wait)
resp = S.post(f"{API}/v1/async/task/batch", json=batch, timeout=60) # same keys: safe to resend
resp.raise_for_status()
record(batch, resp.json()["results"])
In production, wrap the retry in the fuller policy from API errors and retries, and read details.remainingCapacity from a QUEUE_LIMIT_EXCEEDED response to size the next batch.
Idempotency keys
Build keys from the collection key and run:
def idem_key(collection_key: str, attempt: int = 0) -> str:
return f"serp:{collection_key}" + (f":r{attempt}" if attempt else "")
The collection key already includes the date, so re-running a crashed feeder re-submits nothing that was admitted: those items return RESOURCE_ALREADY_EXISTS. Only deliberate resubmissions of failed tasks get a suffix.
Priorities
Use a small number of levels with clear meaning, for example 8 for customer-triggered refreshes, 5 for hot, 3 for core, 1 for tail and deep. Priorities only order your own queue, so a large backfill at priority 1 never delays a customer refresh at 8.
Step 4: size throughput from your own measurements
There is no concurrency cap, so daily capacity is what your queue drains:
tasks per day ≈ measured completions per hour × 24
Measure it, do not assume it, together with D, your mean seconds per task from first start to completion: every completed task reports task.latencyMs (first start to final outcome, excluding queue time), and monitor responses carry X-Latency-Ms. Duration depends on engine, pages, AI Overview and market, so measure per task profile.
Then plan:
- Compute daily checks from the tiered schedule.
- Divide by capacity from the formula. Keep utilization well below 100% to leave room for retries and slow days.
- If a day does not fit, move tiers, not deadlines: shift core to fewer runs or tail to biweekly, or spread submissions more evenly (limits are on /docs/rate-limits).
The 4-hour start bound is irrelevant when a run fits your measured throughput. If you find tasks approaching it, the schedule does not fit your measured throughput.
Step 5: collect results
At this volume, polling task by task is wasteful. Use webhooks:
- Add
webhook: {"url": ...}to every task. - Run several receiver instances behind a load balancer. Each verifies
Webhook-Signatureon the raw body, writes the body to durable storage keyed bytask.id, and answers 2xx within 15 seconds. - Process asynchronously from that storage.
- Reconcile: for tasks whose results have not arrived after their expected finish, fetch
GET /v1/async/task/{taskId}.
Webhook receiver design covers the receiver; SERP API reliability at scale covers the ledger that makes reconciliation cheap.
Step 6: storage layout
Two layers
- Raw layer. The full webhook body per task, compressed, in object storage, path-partitioned by date:
raw/google/2026/09/17/<task_id>.json.gz. Keep the requestpayloadbeside it. The raw layer lets you re-parse history when you add a field or fix an extraction bug, without paying to collect again. - Modeled layer. Extracted rows in a columnar warehouse:
| Table | Grain | Key columns |
|---|---|---|
serp_observations |
One per task | collection_key, observed_date, query, country, has_aio, organic_count, task_id, usd_charged |
serp_organic |
One per organic result | collection_key, observed_date, position, page, domain, url, title |
serp_features |
One per feature instance | collection_key, observed_date, feature (paa, local, ads, aio_source, …), position, domain, text |
project_rankings |
One per subscriber per observation | project_keyword_id, observed_date, best_position, best_url, in_aio_sources |
Partition by observed_date, cluster by country and collection_key (or domain for competitor queries). SERP data in BigQuery has DDL and load code for one warehouse.
Volume estimate
Organic rows dominate: about 10 per page per observation. 36 million observations a month at one page is roughly 360 million organic rows a month, plus features. Estimate from your own observed average row counts and bytes, and set retention per layer: modeled rows for as long as customers chart history, raw bodies for as long as you might re-parse.
Positions: define them once
Decide and document whether “position” counts only organic results (as organicResults[].position does) or includes features. Store organic position as collected and compute any “absolute” position in a view, so the definition can change without rewriting history.
Step 7: data quality before customers see it
At millions of rows, a parsing change or a misconfigured market shows up as millions of false movements. Gate publication on checks:
- Completeness. Share of planned collection keys with a stored, valid observation. Publish per tier only above your threshold; show gaps as gaps, never interpolate silently.
- Structural validity. Organic count within the keyword’s historical range, positions increasing, links present.
- Distribution stability. Per market, compare today’s median organic count, AI Overview rate, local pack rate and ads rate with the trailing week. A shift across thousands of keywords at once is a pipeline signal until proven otherwise.
- Canaries. A fixed set of keywords with well-understood SERPs in every market and slot.
- Movement sanity. Share of keywords whose best position changed by more than N compared with normal days. Hold the run for review when it spikes.
- Target assertions. The stored payload’s
country,pagesandincludeflags match the plan. - Duplicates. One observation per collection key per day; duplicates signal a key-construction bug.
Failed tasks are not charged, and resubmitting them under a new key suffix within the run window closes most completeness gaps. Budget resubmissions per run; a large failure share is an incident to alert on, not something to retry through.
Monthly cost by program
Prices at the Starter tier: Google Search $0.00103 for one page; +$0.00071 per extra page; +$0.00071 for include.aioverview. The price is the same on every tier ($1.03 per 1,000 one-page tasks).
| Program | Calculation | Per month at Starter prices |
|---|---|---|
| 1,000,000 keywords, weekly, one market | 1,000,000 × 4 × $0.00103 | $4,120 |
| 400,000 keywords × 3 markets, daily | 36,000,000 × $0.00103 | $37,080 |
| Same, tiered: 10% daily, 30% three times a week, 60% weekly | 1,200,000 × (0.1 × 30 + 0.3 × 13 + 0.6 × 4) × $0.00103 | about $11,494.80 |
| Hot tier with AI Overview: 50,000 keywords daily | 50,000 × 30 × $0.00174 | $2,610 |
| Deep tier: 20,000 keywords monthly, 10 pages | 20,000 × ($0.00103 + 9 × $0.00071) | $148.40 |
Shared collection reduces the observation count before these multipliers apply; measure your overlap rate across tenants, because on multi-tenant platforms it is often the largest single saving. Tiers and their prices are on /pricing, and cost planning walks through budgeting.
Operating it
- Dashboards: completeness per tier and market, queue depth from
GET /v1/async/status, inbox lag, failure codes, balance fromGET /v1/creditsagainst the month’s budget. - Runbooks: a stalled queue (check queue depth, then engine status), a failure spike (pause the feeder, keep collecting results, resubmit after recovery), a parsing regression (hold publication, re-parse from the raw layer).
- Abort:
DELETE /v1/async/queueremoves every task still queued, which was never charged, and leaves running tasks alone. Record the abort so completeness reflects it. - Tracing: send your own
X-Request-Idper submission so logs join across systems.
Pitfalls
- Dumping the week at once. Feed the queue; spread slots.
- Paying per tenant for the same SERP. Deduplicate on a collection key.
- Random idempotency keys. Re-running a feeder then creates duplicates.
- Assumed task duration. Measure
latencyMsper task profile before promising schedules. - No raw layer. Every extraction fix then requires paid re-collection.
- Publishing before validating. One bad run becomes millions of false alerts.
- Interpolating gaps. Show them; they are information about the pipeline.
See the rank tracking use case and the async tasks docs to start.
Questions
How do I track a million keywords without overwhelming the API?
Spread the work over time and feed the queue instead of dumping it: submit batches of up to 500 tasks while GET /v1/async/status shows capacity, give time-sensitive work a higher priority, and size daily volume to the completion rate you measure.
How many rank checks can I run per day?
There is no concurrency cap and no per-account request rate limit; the async queue holds about 100,000 tasks of a typical prompt. Measure how many tasks complete per hour on your own keywords and markets (task.latencyMs shows per-task duration), multiply by 24, and keep headroom for retries.
What is shared collection in rank tracking?
Collecting each unique combination of query, market and date once and fanning the result out to every project or tenant that tracks it. For platforms with many customers tracking overlapping keywords, it removes duplicate requests before they cost anything.
How should rank tracking data for millions of keywords be stored?
Keep the raw result JSON compressed in object storage so it can be re-parsed, and load extracted rows into a columnar warehouse partitioned by date and clustered by keyword and market. Store the request payload with every result.
What does tracking a million keywords weekly cost?
A Google Search request is $0.00103 for one page at the Starter tier, so one million keywords checked four times a month is about $4,120 a month, before AI Overview (plus $0.00071 each), extra pages (plus $0.00071 each) or additional markets. A 30-day spend that size reaches the Max tier, which lowers the per-request price.