AnswerLineStart free

, Rank Tracking · Engineering · SERP API

Enterprise rank tracking: collecting 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 × devices × runs per month

Every multiplier is a product decision. A platform with 400,000 customer keywords, an average of 1.5 markets, desktop and mobile, checked daily, needs 400,000 × 1.5 × 2 × 30 = 36 million checks a month. 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:

(normalised query, country, hl, location, device, pages, include flags, date)

Normalise 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, coalesce(hl, ''), coalesce(location, ''), device,
                     pages::text, include_aio::text, current_date::text)) as collection_key,
       min(query) as query, country, hl, location, device, 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, 6, 7, 8;

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:

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 at scale

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

Daily capacity follows from concurrency and task duration:

tasks per day ≈ concurrency × 86,400 ÷ D

where D is your mean seconds per task from first start to completion. Measure D, do not assume it: 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:

  1. Compute daily checks from the tiered schedule.
  2. Divide by capacity from the formula. Keep utilisation well below 100% to leave room for retries and slow days.
  3. If a day does not fit, move tiers, not deadlines: shift core to fewer runs or tail to biweekly, or move to a plan with more concurrency (listed on /docs/rate-limits).

The 72-hour start bound is generous for weekly schedules and irrelevant for daily ones if the day fits. If you find tasks approaching it, the schedule does not fit the concurrency.

Step 5: collect results

At this volume, polling task by task is wasteful. Use webhooks:

  1. Add webhook: {"url": ...} to every task.
  2. Run several receiver instances behind a load balancer. Each verifies Webhook-Signature on the raw body, writes the body to durable storage keyed by task.id, and answers 2xx within 15 seconds.
  3. Process asynchronously from that storage.
  4. 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

  1. 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 request payload beside it. The raw layer lets you re-parse history when you add a field or fix an extraction bug, without paying to collect again.
  2. Modeled layer. Extracted rows in a columnar warehouse:
Table Grain Key columns
serp_observations One per task collection_key, observed_date, query, country, hl, location, device, has_aio, organic_count, task_id, credits_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:

  1. 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.
  2. Structural validity. Organic count within the keyword’s historical range, positions increasing, links present.
  3. Distribution stability. Per market and device, 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.
  4. Canaries. A fixed set of keywords with well-understood SERPs in every market and slot.
  5. 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.
  6. Target assertions. The stored payload’s country, location, device and hl match the plan.
  7. 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.

Cost at scale

Credit costs: Google Search async task 3 credits for one page; +2 per extra page; +2 for include.aioverview; synchronous calls +2 (avoid them for bulk work).

Program Calculation Credits per month
1,000,000 keywords, weekly, one market, desktop 1,000,000 × 4 × 3 12,000,000
400,000 keywords × 1.5 markets × 2 devices, daily 36,000,000 × 3 108,000,000
Same, tiered: 10% daily, 30% three times a week, 60% weekly 1,200,000 × (0.1 × 30 + 0.3 × 13 + 0.6 × 4) × 3 about 33,500,000
Hot tier with AI Overview: 50,000 keywords daily 50,000 × 30 × 5 7,500,000
Deep tier: 20,000 keywords monthly, 10 pages 20,000 × (3 + 9 × 2) 420,000

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. Plans and enterprise options are on /pricing, and cost planning walks through budgeting.

Operating it

Pitfalls

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 what your concurrency slots can finish.

How many rank checks can I run per day?

Roughly concurrency slots × 86,400 ÷ your measured mean seconds per task. Measure the mean from task.latencyMs on your own keywords and markets, and keep headroom for retries; concurrency per plan is listed in the rate limits docs.

What is shared collection in rank tracking?

Collecting each unique combination of query, market, device 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 be stored at scale?

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 in credits?

A Google Search async task is 3 credits for one page, so one million keywords checked four times a month is 12 million credits, before AI Overview (plus 2 each), extra pages (plus 2 each) or additional devices and markets.

Try it on your own prompts

500 free credits a month, no card. One POST returns the answer, sources and citations as JSON.

Keep reading