A 100,000-row enrichment job is not a bigger version of a 500-row one. At 500 rows you run a loop, and if it dies you run it again. At 100,000 rows the job runs for hours, it will be interrupted (a deploy, a laptop lid, an out-of-credits refusal at row 61,000), and running it again from the top pays for the first 61,000 rows twice. The work in this lesson is making that impossible.
Two kinds of volume show up in practice, and they need different tools:
- Many inputs, one answer each. A CSV of 100,000 emails, domains or IPs. You control the list; the job is chunking, pacing and resuming.
- One input, many answers. A listing that comes back a page at a time, like every open job at a company. The API controls the list; the job is walking pages and merging them correctly.
Do the arithmetic before the first call
Two numbers decide whether a bulk job is feasible: credits and time. Credits are lesson 4. Time is set by the rate limit, and batching does not change it, because a batch commits one slot of your per-minute window for every item it runs.
At a window of L requests per minute, N lookups take at least N / L minutes. With 70,000 unique inputs:
| Window (requests/minute) | Minimum run time |
|---|---|
| 60 | about 19.5 hours |
| 180 | about 6.5 hours |
| No per-minute limit | bounded by latency and your own concurrency |
Read the window your key actually has from x-rate-limit-limit on any response (server-side; browsers cannot read it), or from rateLimit in the free GET /v1/analytics summary, which reports a string like 60 requests/minute or unlimited.
Two decisions follow from that table:
- Deduplicate first. It cuts time and credits in the same proportion. Lesson 2's
planBatchesdoes it. - Give the job its own sub-key. A sub-key has its own rate-limit window, so a backfill that saturates its window does not starve the interactive traffic on your main key. It still spends the account's shared credits; it fences off rate, not money. On Mega you can also cap a sub-key's per-minute rate below the plan ceiling.
Checkpoints keyed by input, not by position
The tempting checkpoint is a row number: "processed up to 61,000". It breaks as soon as the input changes between runs (a re-export reorders rows, someone removes a duplicate) and it cannot express "rows 60,801 to 61,000 were sent but the response never arrived".
Checkpoint by the canonical key of each input instead, in an append-only file of results. On start, the file tells you which keys are done; everything else is still to do. A crash can lose at most the batch that was in flight.
import { createReadStream } from 'node:fs';
import { appendFile } from 'node:fs/promises';
import { createInterface } from 'node:readline';
export async function loadDone(path) {
const done = new Map(); // key -> envelope
try {
const lines = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
for await (const line of lines) {
if (!line.trim()) continue;
try {
const { key, result } = JSON.parse(line);
done.set(key, result);
} catch { /* a torn last line from a crash: ignore it, the key will be redone */ }
}
} catch (err) {
if (err.code !== 'ENOENT') throw err; // first run: no file yet
}
return done;
}
export async function record(path, entries) {
// One appendFile per batch: a crash loses at most this batch, never earlier ones.
await appendFile(path, entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
}JSON Lines suits this well: appending never rewrites earlier lines, and a crash in the middle of a write damages only the last line, which loadDone skips. Store errors that are the input's own answer (an invalid email, nothing found) in the same file, so a resume does not pay rate limit to hear the same verdict again. Do not record 429 or 402 items: they were never answered, and the next run must send them.
The job
This ties lesson 1's buildQuery, lesson 2's batch sender and the checkpoint together:
import { buildQuery } from './query.mjs';
import { runBatches } from './run-batches.mjs';
import { loadDone, record } from './checkpoint.mjs';
export async function enrich(api, rows, toParams, { out = `${api}.results.jsonl`, chunk = 200 } = {}) {
const done = await loadDone(out);
// Unique, not-yet-done inputs, in first-seen order.
const todo = new Map();
for (const row of rows) {
const params = toParams(row);
const key = buildQuery(params);
if (!done.has(key) && !todo.has(key)) todo.set(key, params);
}
console.log(`${done.size} already done, ${todo.size} to run`);
const keys = [...todo.keys()];
for (let i = 0; i < keys.length; i += chunk) {
const slice = keys.slice(i, i + chunk);
const { results, pending } = await runBatches(api, slice.map((k) => todo.get(k)), { size: chunk });
const entries = [...results].map(([j, result]) => ({ key: slice[j], result }));
await record(out, entries);
for (const e of entries) done.set(e.key, e.result);
if (pending.length) throw new Error(`${pending.length} items still rate-limited; rerun to resume`);
}
// Join back to the input, in input order: one answer per row, duplicates included.
return rows.map((row) => done.get(buildQuery(toParams(row))) ?? null);
}Three properties make this safe to kill and rerun at any point:
- Idempotent by key. Rerunning skips every key in the results file. A row that appears 400 times in the input is looked up once and answered 400 times in the join.
- Bounded loss. Results are recorded per batch, before the next batch starts. The worst case after a crash is one batch sent again.
- Output order is input order. Work happens in whatever order is efficient; the final
mapoverrowsrestores the original order. Never rely on completion order for anything a person will read.
When runBatches throws on 402, the completed results from that round are attached to the error as done. Catch it in enrich and record them before rethrowing if you want to keep the last partial batch too; otherwise those few items are simply redone on the next run.
Concurrency: fewer workers than you think
Running several batches in parallel looks like the obvious speed-up. Against one key it mostly is not: every batch draws from the same per-minute window, so two parallel batches reach the limit twice as fast and then both wait. Concurrency helps with latency, not with rate. It pays when the endpoint is slow, so that one batch's wait overlaps another's work, or when the key has no per-minute limit.
When you do run in parallel, cap it with a small pool rather than an unbounded Promise.all:
export async function pool(tasks, limit) {
const results = new Array(tasks.length);
let next = 0;
async function worker() {
while (next < tasks.length) {
const i = next++;
results[i] = await tasks[i]();
}
}
await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
return results;
}Start at two workers for batches and measure. If rateLimited climbs, you have more concurrency than window; adding workers will only add 429s and lengthen the edge's cooldown on the key.
A resumable job checkpoints the last processed row number. Between two runs, someone re-exports the input CSV sorted by a different column. What is the most likely result of resuming?
Paginated results
Some APIVerve endpoints return lists a page at a time. Job Postings is a good example. Give it a company's career-board name and it returns that company's open jobs, newest first, with paging metadata:
curl "https://api.apiverve.com/v1/jobpostings?company=airbnb&page=2" \
-H "x-api-key: YOUR_API_KEY"Look at total, page, pages and count beside the jobs array. Each job has an id. Paid plans get 50 jobs per page, the free plan 10.
When we called it, Airbnb's board had total: 196 across pages: 4 at 50 per page. The fields you page with:
| Field | Meaning |
|---|---|
page | The page returned |
pages | Pages of matching jobs, given the filters and your plan's page size |
total | Jobs matching the filters |
count | Jobs on this page |
truncated | true when the company has more open jobs than are returned across all pages |
jobs[].id | The job's id on the company's career board |
Its sibling Job Search pages differently: it returns page, count and hasMore ("another page may hold more matching jobs") with no total. Two stop conditions cover both styles: stop when page >= pages, or when hasMore is false. Add an empty page and a hard maximum as backstops, because a loop whose stop condition depends on a field you misspelt runs until the credits do.
Why pages overlap
A listing sorted newest first is not a snapshot. If a company posts a job between your request for page 1 and your request for page 2, every job shifts down one position, and the last job of page 1 appears again as the first job of page 2. If a job closes, everything shifts up one, and a job you have not seen yet slides from page 2 onto page 1, which you already read: you miss it.
Duplicates are the visible symptom; misses are the silent one. Handle duplicates by merging on a stable id. Detect misses by comparing what you merged with what the API said exists.
mergePages
export function mergePages(pages) {
const seen = new Set();
const merged = [];
for (const page of pages) {
for (const item of page ?? []) {
if (seen.has(item.id)) continue; // first occurrence wins
seen.add(item.id);
merged.push(item);
}
}
return merged;
}
mergePages([[{ id: 'a' }, { id: 'b' }], [{ id: 'b' }, { id: 'c' }], []]);
// [{ id: 'a' }, { id: 'b' }, { id: 'c' }]The design choices:
- Deduplicate by
id, never by whole-object equality. A job fetched twice a minute apart can differ inupdatedAtand still be the same job. Deep comparison would keep both. - First occurrence wins, and order is preserved. The merged list keeps the API's ordering (newest first here) because each item stays where it first appeared. If you need the freshest copy of each item instead, overwrite the stored item in place while keeping its position; do not move it to the end.
- Do not re-sort by id. Ids on a job board are not dates. Sorting by them destroys the ordering the API spent effort producing.
- Tolerate a missing page.
page ?? []lets you pass the result of a failed fetch asnullwithout crashing the merge; your caller should still log that a page was missing. - Compare ids as they come. The API returns job ids as strings. Do not coerce types unless an API mixes them; coercion hides bugs as often as it fixes them.
The same function in Python, for completeness:
def merge_pages(pages):
seen, merged = set(), []
for page in pages:
for item in page or []:
if item["id"] in seen:
continue
seen.add(item["id"])
merged.append(item)
return mergedWalk all pages, then check
import { mergePages } from './pages.mjs';
async function jobsPage(company, page) {
const url = new URL('https://api.apiverve.com/v1/jobpostings');
url.searchParams.set('company', company);
url.searchParams.set('page', String(page));
const res = await fetch(url, {
headers: { 'x-api-key': process.env.APIVERVE_API_KEY },
signal: AbortSignal.timeout(30_000),
});
const body = await res.json().catch(() => null);
if (!res.ok || body?.status !== 'ok') throw Object.assign(new Error(body?.error || `HTTP ${res.status}`), { status: res.status });
return body.data;
}
export async function allJobs(company, { maxPages = 40 } = {}) {
const pages = [];
let total = null;
for (let page = 1; page <= maxPages; page++) {
const data = await jobsPage(company, page);
total = data.total;
pages.push(data.jobs);
if (!data.jobs?.length || page >= data.pages) break;
}
const jobs = mergePages(pages);
return { jobs, total, complete: jobs.length >= total };
}complete is the miss detector. If the merged count is below the total the API reported, something shifted under you while you paged. For a listing like this, the cheap fix is to fetch page 1 again and merge it in: new jobs appear at the front, so that is where the gap usually is. For anything that must be exact, re-walk the listing until two consecutive passes agree.
Each page is one call at the endpoint's full price: Job Postings costs 10 credits a page. At 50 per page the 196 Airbnb jobs are 4 calls; at the free plan's 10 per page they are 20 calls, five times the credits for the same data. Page size is a cost variable, and if you only need some of the jobs, a filter such as keyword or department is applied before paging, which is cheaper than paging through everything and discarding most of it.
You page through a newest-first listing. Page 1 ends with job j50; page 2 starts with j50 again. Merged with mergePages, the result has 195 jobs while the response said total: 196. What most likely happened?
The bulk-job checklist
Before you start anything that will run for hours:
- Unique inputs counted, and the estimated run time computed from the key's real window.
- A sub-key for the job, so its rate window is its own.
- Results appended per batch, keyed by canonical input, with input-level errors recorded and
429or402items left for the next run. - A concurrency cap you measured, not guessed.
- For paginated sources: a stop condition, a hard page maximum, merge by id, and a completeness check against the reported total.
All of that keeps the job correct. None of it stops it spending more than you meant to. That is the last lesson: Control credit spend.