DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Newsletter
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Decoupling a Data Pipeline From the Tables It Was Never Supposed to Depend On
  • Engineering Audit-Ready Data Pipelines for Regulatory Reporting
  • Why Real-Time Data Pipelines Are Becoming the Foundation of Industrial AI
  • Ampere PMU Profiler: A Guide to Microarchitecture Profiling

Trending

  • When 12 Hours of Offline Changes Collide: Designing Conflict-Safe Mobile Sync
  • Genkit Flows on Kubernetes, Routed by a Decision Model: senro + jev in Go
  • Part 4: Multi-Agent Orchestration — Pairing Goose With Quarkus Flow State Machines
  • When a Coding Agent Fixes the Test Instead of the Bug

Draft This article hasn't been published, so readers can't see it.

In moderation This article is waiting for review, so readers can't see it yet.

Deleted This article has been deleted, so readers can't see it.

  1. DZone
  2. Data Engineering
  3. Data
  4. Designing a Polling Pipeline for Rate-Limited APIs Without Losing Data

Designing a Polling Pipeline for Rate-Limited APIs Without Losing Data

Learn four common API polling failure modes and how adaptive intervals, watermark overlap, content-based deduplication, and retry backoff prevent data loss.

By 
CARTER WANG
·
Nov. 06, 26 · Analysis
Post on X (opens in a new tab) Share on LinkedIn (opens in a new tab)
27 Views

Join the DZone community Save articles, join the discussion, and download Refcards and Trend Reports with a free account.

Join for free Already a member? Log in

Every system that ingests data from someone else's API eventually writes a poller. It starts as fifteen lines in a cron job: hit the endpoint, compare against the last timestamp, insert what's new. It works immediately, which is the problem — it keeps appearing to work long after it has started losing records.

I maintain an ingestion layer that polls public discussion APIs continuously, and the gap between "the poller is running" and "the poller is correct" took considerably longer to close than I expected. What follows are the four failure modes that caused real data loss, and the patterns that fixed them.

None of this is exotic. It is the set of things I wish had been in one place before I wrote the first version.

Failure Mode 1: The Fixed Interval

The naive poller runs on a fixed schedule — every 60 seconds, every source, forever.

This is wrong in both directions simultaneously. High-volume sources produce more records between polls than a single page returns, so you lose everything past the page boundary. Low-volume sources return nothing on the vast majority of calls, burning quota that the high-volume sources needed.

With a shared quota across sources, the fixed interval guarantees you are under-sampling exactly where the data is.

The fix is to let observed yield drive the interval. If the last poll came back full, poll sooner. If it came back empty, back off.

TypeScript
 
interface PollState {
  sourceId: string;
  intervalMs: number;
  consecutiveEmpty: number;
}

const MIN_INTERVAL = 30_000;
const MAX_INTERVAL = 1_800_000; // 30 min

function nextInterval(state: PollState, recordsFound: number, pageSize: number): number {
  // Page came back full — we are probably truncating. Poll aggressively.
  if (recordsFound >= pageSize) {
    return MIN_INTERVAL;
  }

  if (recordsFound > 0) {
    // Productive but not saturated. Ease toward the current rate.
    return clamp(state.intervalMs * 0.8, MIN_INTERVAL, MAX_INTERVAL);
  }

  // Empty. Back off geometrically, but bounded.
  const factor = 1.5 ** Math.min(state.consecutiveEmpty, 6);
  return clamp(MIN_INTERVAL * factor, MIN_INTERVAL, MAX_INTERVAL);
}

function clamp(n: number, lo: number, hi: number): number {
  return Math.min(hi, Math.max(lo, n));
}


The condition worth highlighting is recordsFound >= pageSize. A full page is not a success signal — it is evidence that records may exist beyond what you fetched. Treat it as a truncation warning and either paginate immediately or shorten the interval. Systems that treat a full page as "great, got everything" lose data proportional to source velocity, which means they lose the most from the sources that matter most.

After deploying adaptive intervals, our total request volume dropped by roughly 40% while records captured went up, because quota moved from silent sources to active ones.

Failure Mode 2: The Watermark Boundary

The standard incremental pattern stores the timestamp of the newest record seen and requests everything after it:

SQL
 
SELECT * FROM source WHERE created_at > :watermark ORDER BY created_at


This loses records, for three independent reasons.

  • Clock skew. The upstream timestamp is assigned by their server, not yours. Distributed writers mean created_at is not guaranteed to be monotonic in the order records become visible to the API.
  • Visibility lag. A record can be written at T but not appear in query results until T+2s due to replication or index lag. Poll at T+1s, advance the watermark past T, and that record is never returned.
  • Ties. Multiple records sharing the same timestamp, split across a page boundary, silently drop the remainder when you use a strict >.

The fix is to rewind the watermark by a safety margin and deduplicate downstream:

TypeScript
 
const OVERLAP_MS = 120_000; // tune to observed upstream lag

function queryWindow(watermark: number): { since: number; until: number } {
  return {
    since: watermark - OVERLAP_MS,
    until: Date.now(),
  };
}

This deliberately re-fetches records you already have. That is the point — you trade a small amount of redundant bandwidth for the guarantee that a record has to be missed by two consecutive polls to be lost permanently.

Size OVERLAP_MS From measurement, not intuition. Log the delta between upstream created_at and your own ingest time, take the 99th percentile, and double it. Ours sat around 40 seconds at p99, so 120 seconds gives real headroom without meaningful cost.

Failure Mode 3: Deduplication That Isn't

Overlap windows only work if dedup is correct, and ID-based dedup handles less than people assume.

Upstream records get edited. A post whose body changes after ingestion keeps its ID, so an INSERT ... ON CONFLICT DO NOTHING on the ID silently discards the update. Meanwhile, some sources reuse or re-issue identifiers across deletion, so the ID alone is not always a stable key either.

Hash the fields that constitute a meaningful change, and store both:

TypeScript
 
import { createHash } from "node:crypto";

function contentHash(record: SourceRecord): string {
  // Only fields whose change means "this record is materially different."
  // Volatile counters (score, comment count) are excluded on purpose.
  const canonical = JSON.stringify({
    id: record.id,
    title: record.title ?? "",
    body: record.body ?? "",
    author: record.author ?? "",
  });
  return createHash("sha256").update(canonical).digest("hex");
}


SQL
 
INSERT INTO records (source_id, external_id, content_hash, payload, first_seen_at, last_seen_at)
VALUES ($1, $2, $3, $4, now(), now())
ON CONFLICT (source_id, external_id) DO UPDATE
SET content_hash  = EXCLUDED.content_hash,
    payload       = EXCLUDED.payload,
    last_seen_at  = now(),
    revision      = records.revision + 1
WHERE records.content_hash IS DISTINCT FROM EXCLUDED.content_hash;


Two details carry most of the weight. The WHERE clause means the overlap window costs nothing when nothing changed — no write amplification, no spurious updated_at churn, and downstream consumers watching for changes don't get woken up by re-fetches.

And excluding volatile counters from the hash is what makes this viable at all. Include a score that ticks every few seconds and every single poll registers as a change, which defeats the purpose and turns your overlap window into a write storm.

Failure Mode 4: Retry Storms and Undetected Gaps

When a rate-limited API returns 429, the instinct is to retry. Retry immediately, and you extend the penalty. Retry on a fixed delay across N workers, and they resynchronize into a thundering herd that hits the limit together, forever.

Most rate-limited APIs tell you what you need in response headers. Read them instead of guessing:

TypeScript
 
interface RateLimitState {
  remaining: number;
  resetAt: number;
}

function parseRateLimit(res: Response): RateLimitState | null {
  const remaining = res.headers.get("x-ratelimit-remaining");
  const reset = res.headers.get("x-ratelimit-reset");
  if (remaining === null || reset === null) return null;

  return {
    remaining: Number(remaining),
    // Some APIs send seconds-until-reset, others an absolute epoch. Normalize.
    resetAt: Number(reset) > 1e9 ? Number(reset) * 1000 : Date.now() + Number(reset) * 1000,
  };
}

async function backoff(attempt: number, res?: Response): Promise<void> {
  const retryAfter = res?.headers.get("retry-after");
  if (retryAfter) {
    const ms = Number(retryAfter) * 1000;
    if (Number.isFinite(ms)) return sleep(ms);
  }

  // Full jitter — see AWS Builders' Library on backoff.
  const ceiling = Math.min(60_000, 1000 * 2 ** attempt);
  return sleep(Math.random() * ceiling);
}

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));


Full jitter matters more than the backoff curve. Exponential backoff with a deterministic delay keeps clients synchronized; randomizing across the whole interval is what actually spreads load. Retry-After, When present, it is authoritative — honor it over your own calculation.

The subtler problem is that these failures are invisible. A source that throws 429 for six hours produces no errors in your application logs if the poller catches and backs off correctly. It just quietly returns nothing, and your data has a hole in it that nobody notices until someone asks why a report looks wrong.

Detect gaps explicitly rather than inferring them from error rates:

TypeScript
 
async function detectGaps(sourceId: string): Promise<Gap[]> {
  const { rows } = await db.query(`
    SELECT
      window_end   AS gap_start,
      LEAD(window_start) OVER (ORDER BY window_start) AS gap_end
    FROM poll_log
    WHERE source_id = $1 AND status = 'ok'
      AND window_start > now() - interval '48 hours'
    ORDER BY window_start
  `, [sourceId]);

  return rows
    .filter(r => r.gap_end && r.gap_end - r.gap_start > GAP_THRESHOLD_MS)
    .map(r => ({ sourceId, from: r.gap_start, to: r.gap_end }));
}


This requires logging every poll attempt with its requested window and outcome — a table most people skip because it feels like overhead until the first time it tells you about four hours of missing data you would otherwise never have found. Feed detected gaps into a low-priority backfill queue that runs when quota is available.

What Actually Matters

If you take three things from this:

  • Log the window, not just the result. Without a record of what range each poll requested, gaps are undetectable. This is the single highest-value thing to add to an existing poller.
  • Overlap deliberately, dedup by content. A safety margin plus content-hash dedup converts "we might have missed something" into "a record must be missed twice to be lost."
  • Treat a full page as a warning. It is the most common silent truncation point in production pollers, and it is one comparison to check for.

None of these are difficult. They are just easy to omit, and their absence produces a system that looks healthy while quietly dropping records.


I work on the ingestion layer at SignalMelo, which polls public discussion sources continuously — the patterns above came out of production incidents there. Disclosure: it's a commercial product I help build.

Data (computing) Pipeline (software)

Opinions expressed by DZone contributors are their own.

Related

  • Decoupling a Data Pipeline From the Tables It Was Never Supposed to Depend On
  • Engineering Audit-Ready Data Pipelines for Regulatory Reporting
  • Why Real-Time Data Pipelines Are Becoming the Foundation of Industrial AI
  • Ampere PMU Profiler: A Guide to Microarchitecture Profiling

Partner Resources

×

Comments

Loading comments

The comments didn't load

Please try again in a moment.

Likes

Loading

The next likes didn't load. Please try again.

The likes didn't load

Please try again in a moment.

See who liked this

Log in or join DZone for free to see who liked this article.

Log in Join for free
  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook