Most Amazon Data API Node.js guides stop at await fetch returning JSON. Between that line and clean data delivered on time every day sit three things the TypeScript compiler cannot help with: network fingerprints, the concurrency model, and idempotent reruns. Node has no default answer on fingerprints the way Python has. The concurrency model changes wholesale once HTTP/2 is negotiated, which makes the concurrency caps you see in most guides meaningless. Idempotent reruns decide whether a failure means “run it again” or “duplicate rows.” This piece walks that order, with runnable TypeScript at each step, plus where you should stop and buy the layer instead.
1. Why Node teams break on this first
Put the script from a standard Node guide into cron and the first three days look great. Trouble surfaces in week three to five, and never as an error.
1.1 Three failures that raise no alarm
| Symptom | What the dashboards show | Which layer is missing |
|---|---|---|
| A challenge page comes back | res.status still 200, the fields are absent | A test for whether the response holds real data |
| Fields go empty in silence | Types compile, the field is null at runtime | An assertion that this field must have a value |
| Re-run after an interrupt | Row count grows, the analyst starts complaining | A primary key and idempotent writes |
What these three share is quiet monitoring. Status codes, exception counts, and process exit codes all look fine while the report drifts. Expecting alerts to catch them means asking your monitoring to guess business meaning.
1.2 Code that compiles is not data that is right
TypeScript teams fall into a specific trap here. You define an interface, the compiler checks field names, optionality, and nesting, and it looks as though validation happened. What type annotations constrain is your own code, not the JSON arriving over the wire. If upstream turns price from a number into a string, or stops returning the key, the compiler stays silent, because at compile time it never sees the data.
The gap on the network side works the same way, and Node has more of it. That change does not come from upstream. It comes from the HTTP client you chose, and many teams only learn the layer exists once they are blocked.
1.3 Count the rework too
A local script that errors costs thirty seconds to fix and rerun. Three weeks of duplicate rows in production cost you every downstream report that consumed them, and you may not know which reports those were. Hours saved on structure come back with interest at the first data incident.
That boundary sets the structure below: the first three sections cover whether your request counts as normal traffic, the next five cover whether the data can line up daily, and the last few cover whether the job can run for months without trouble.
2. Which HTTP clients Node.js teams have to choose from
Choosing a client is not about ergonomics. It is about how much code you still have to write. Start with the table, then look at why some rows are traps.
| Client | HTTP/2 | TLS fingerprint | Status in 2026 |
|---|---|---|---|
fetch (built in) | Node 20 does not pick h2 by default, needs an explicit agent | Node’s own OpenSSL handshake | Zero setup, two layers left to you |
undici | Needs allowH2: true | Same as above | Node-official, pool under your control |
axios | No support | Same as above | Largest ecosystem, no multiplexing |
got | Supported | Same as above | General purpose, no fingerprint layer |
got-scraping | — | Rewrites headers only | No longer maintained |
impers | Supported | curl-impersonate core | Node binding, close to the curl_cffi experience |
wreq-js | Supported | Rust plus BoringSSL, in-process | Highest throughput, ships a native binary |
One piece of history tends to get missed. got-scraping used to be the default answer for collection in the Node world, and it is no longer maintained. More to the point, it only ever rewrote request headers, and it never touched the TLS handshake. Anything blocking you on JA3 or JA4 was never something it could fix — that is a capability boundary, not a version lag. If your stack rests on that class of library, upgrading will not move you.
2.1 What the benchmark table tells you
The fingerprint libraries differ by more than throughput. Figures below come from the benchmark published in the wreq-js repository, measured 2026-08-06 on an M-series Mac, 300 sequential requests against a local server, so they reflect the JavaScript-to-native boundary rather than network latency. Numbers move with versions, so reproduce them before you rely on them.
| Library | Engine | Newest Chrome | HTTP/2 fingerprint correct | req/s | Cold start |
|---|---|---|---|---|---|
wreq-js | Rust wreq plus BoringSSL, in-process | 149 | Yes | 12842 | 7 ms |
impers | curl-impersonate, in-process | 146 | Yes | 8439 | 16 ms |
node-wreq | Same Rust core | 149 | Yes | 6500 | 10 ms |
impit | Rust reqwest plus patched rustls | 124 | No | 6710 | 37 ms |
CycleTLS | Go subprocess with IPC | not tested | not tested | not tested | IPC cost per request |
Throughput is not the column that matters most here. The column that does is the one marked “HTTP/2 fingerprint correct.”
2.2 Claiming to be Chrome, speaking Rust
A client can pass as Chrome at the TLS layer and give itself away one layer up. The reason that row fails for impit is specific: its HTTP/2 SETTINGS come from the defaults of the underlying Rust HTTP library rather than what Chrome sends. It omits HEADER_TABLE_SIZE and sends a MAX_FRAME_SIZE that Chrome never sends. The result is that anything hashing that frame sees a client claiming to be Chrome while speaking HTTP/2 like a Rust program.
That kind of mismatch reads worse than being behind. Defenses hunt for contradictions, not old builds. A Windows User-Agent paired with a Linux-shaped HTTP/2 window, or an Accept-Language of de-DE from a US exit, are the same mistake: signals that deny each other.
This is also where Node is harder than Python. In the Python world, curl_cffi is close to the single answer, and it solves the OpenSSL swap and the browser profile as one unit. The Amazon Data API Python walkthrough covers that path in full. Node has no equivalent default. The options above take different routes — Rust native, Go subprocess, curl binding — and each covers the problem to a different extent, which means you verify rather than trust.
2.3 Sessions versus one-off calls
One more cost sits here, independent of language and often missed in Node. A bare fetch call opens a new connection, so it pays a full TLS handshake each time, while a session or pool reuses it. Published benchmarks put the gap at about 53 ms against 15 ms. On a batch job, that handshake cost converts into job duration.
// Costs a full handshake on every call
for (const asin of asins) {
await fetch(buildUrl(asin));
}
// Reuses TLS sessions and cookies
const session = await createSession({ browser: "chrome_149" });
try {
for (const asin of asins) {
await session.fetch(buildUrl(asin));
}
} finally {
await session.close();
}
3. TLS and HTTP/2 fingerprints: verifying the change
In Node you verify this yourself, because no compiler or type system covers it. Two steps.
3.1 Step one: call a public fingerprint endpoint
Do not treat “returns 200” as passing. Challenge pages return 200 as well. Call a public fingerprint service and compare the JA3 or JA4 you get back against the browser build you claim to imitate.
const res = await session.fetch("https://tls.peet.ws/api/all", {
headers: { "accept-language": "en-US,en;q=0.9" },
});
const fp = await res.json();
console.log(fp.ja4, fp.akamai_h2); // compare against real browser values
While we are here, the JA3 and JA4 distinction explains a common dead end. JA3 concatenates five ClientHello fields in order and hashes them. Chrome 110 and later shuffle the extension order, which moves JA3 with it, so mainstream defenses moved to JA4, which sorts before hashing. That is why fixing JA3 can change nothing at all.
3.2 Step two: check that the five signals share one source
A matching TLS fingerprint is not enough on its own. These signals have to come from one profile:
| Signal | Common mismatch |
|---|---|
| User-Agent against platform | Windows UA with a Linux-shaped HTTP/2 window |
| Accept-Language against storefront | en-US against amazon.de |
| Exit country against storefront | German storefront from a US exit |
| Timezone against IP origin | UTC timezone with a European IP |
| TLS against HTTP/2 fingerprint | Chrome 149 TLS with Rust-default HTTP/2 SETTINGS |
One caution if you go native: prebuilt binaries and Rust toolchains behave in their own way across base images. Alpine (musl) and Debian (glibc) need separate verification. Run one cold start inside your container before the first scheduled trigger finds out for you.
4. Turning on HTTP/2 changes the concurrency model
This is the section most often skipped and most likely to cost a day. The claim up front: the concurrency cap you see in most guides assumes HTTP/1.1. Once the connection negotiates HTTP/2, that number means something else.
4.1 The built-in fetch does not negotiate HTTP/2 by default
Node’s fetch sits on undici but does not select h2 during ALPN. Maintainers describe HTTP/2 support as experimental and off by default, which means an explicit agent:
import { Agent, setGlobalDispatcher, fetch } from "undici";
setGlobalDispatcher(new Agent({ allowH2: true })); // required to use HTTP/2
const res = await fetch("https://api.example.com/v1/product");
If you use undici’s Client instead, the allowH2 default there is true. One library, two entry points, two defaults. That gap is its own class of incident: the same business code changes its connection behavior when you swap the calling layer.
4.2 The ceiling moves from pipelining to maxConcurrentStreams
The key sentence is this: once HTTP/2 is negotiated, what caps in-flight requests over one connection is no longer pipelining but maxConcurrentStreams, default 100.
Under HTTP/1.1 a connection handles one request at a time, so “concurrency” is close to “number of connections.” Under HTTP/2 a single connection carries hundreds of streams, and the constraint becomes three separate things:
| Layer | Meaning under HTTP/1.1 | Meaning once HTTP/2 is live |
|---|---|---|
App-level concurrency (p-limit and friends) | Near enough to requests in flight | Still caps what your code issues at once |
Stream count maxConcurrentStreams | Does not exist | Hard cap per connection, overridable by the server SETTINGS frame |
Flow window initialWindowSize | Does not exist | Defaults to 262144, throughput stalls when it drains |
So raising concurrency from 8 to 64 does two different things depending on the protocol. On HTTP/1.1 it multiplies connection pressure. On HTTP/2, if streams or the window top out first, the higher app-level number only lengthens the queue.
4.3 The two-layer sandwich
Write concurrency control in two places: an app-level queue cap that decides how many requests your code issues, plus transport-level pool and HTTP/2 settings that decide how those requests land on connections.
import { Agent } from "undici";
import pLimit from "p-limit";
// Transport: pool plus HTTP/2 parameters
const dispatcher = new Agent({
allowH2: true,
connections: 8, // connections per origin
pipelining: 0,
maxConcurrentStreams: 100, // default HTTP/2 cap, tune per server SETTINGS
bodyTimeout: 30_000,
headersTimeout: 15_000,
connect: { timeout: 5_000 },
});
// App layer: requests in flight
const limit = pLimit(16);
async function run(jobs: Job[]): Promise<Result[]> {
return Promise.all(jobs.map((job) => limit(() => fetchOne(dispatcher, job))));
}
Tune in a fixed order. Hold app-level concurrency steady and move connections first, watching the 429 share and end-to-end latency. When throughput stops rising and queue time starts growing, step back one notch. The ceiling is, in most cases, the grant upstream gave you rather than the cores you own.
5. Request layer: timeouts, credentials, retries in one class
The first piece of engineering is to pull parameters scattered across files into one object, so that “one call” has a single definition. Three rules hold: give timeouts per phase, read credentials from the environment, and keep retries inside the class while returning the attempt count.
5.1 Why timeouts belong to separate phases
The common Node approach is a single AbortSignal.timeout(30_000) for everything. That one value covers three different questions: how long to connect, how long to wait for headers, and how long to stream the body. One number means the loosest constraint wins across all three, so a slow connection holds workers open.
import { Agent } from "undici";
type Marketplace = "US" | "DE" | "JP" | "UK";
interface FetchOutcome<T> {
ok: boolean;
status: number;
body: T | null;
attempts: number;
error?: string;
}
export class AmazonClient {
private readonly dispatcher: Agent;
constructor(private readonly baseUrl: string, opts: Partial<ClientOpts> = {}) {
this.dispatcher = new Agent({
allowH2: true,
connections: opts.connections ?? 8,
connect: { timeout: 5_000 }, // connect phase
headersTimeout: 15_000, // waiting for headers
bodyTimeout: 30_000, // streaming the body
});
}
}
Each value caps its own phase, so the stage most exposed to long tails has its own limit. Keep attempts in the return type — it is a variable in the cost formula and a signal that upstream is degrading.
5.2 Where retries stop
Retries belong to two classes only: transient failures (timeouts, reset connections, 502 or 504) and throttling (429, or a 503 carrying Retry-After). The third case is the one most often handled wrong: a 200 with missing fields is not a failure, it is a contract breach, and retrying it a hundred times returns the same empty field at a hundred times the cost.
const LANG: Record<Marketplace, string> = {
US: "en-US,en;q=0.9",
DE: "de-DE,de;q=0.9",
JP: "ja-JP,ja;q=0.9",
UK: "en-GB,en;q=0.8",
};
async function withRetry<T>(
fn: () => Promise<T>,
opts: { maxAttempts?: number; baseDelayMs?: number } = {},
): Promise<{ value: T; attempts: number }> {
const maxAttempts = opts.maxAttempts ?? 3;
const base = opts.baseDelayMs ?? 1000;
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return { value: await fn(), attempts: attempt };
} catch (err) {
lastError = err;
if (!isTransient(err) || attempt === maxAttempts) throw err;
// Exponential backoff plus jitter, so failures do not resend in lockstep
const wait = base * 2 ** (attempt - 1) + Math.random() * 400;
await new Promise((r) => setTimeout(r, Math.min(wait, 30_000)));
}
}
throw lastError;
}
Do not drop the jitter. Exponential backoff without it sends the whole failed batch at the same instant next round, manufacturing the burst you were trying to avoid.
5.3 The error names Node gives you
Node’s error model is its own, so decide retryability by name rather than by status code alone:
| Source | Typical cause | Action |
|---|---|---|
UND_ERR_CONNECT_TIMEOUT | Connect phase timed out | Retry |
UND_ERR_HEADERS_TIMEOUT | Headers never arrived | Retry |
UND_ERR_BODY_TIMEOUT | Body stream stalled | Retry, and log body size |
DOMException TimeoutError | AbortSignal.timeout fired | Retry |
ENOTFOUND / EAI_AGAIN | DNS resolution failed | Retry, and investigate egress if it repeats |
| HTTP 403 or 429 | Challenge or throttling | Back off on 429 per Retry-After; on 403 change tier and shed load |
| JSON parse failure | Body is not JSON | A challenge page in most cases, not a transient error |
That last row deserves its own note. Parse failures tend to mean an HTML challenge page came back, not that the network hiccuped. Filing it as transient means you spend many attempts knocking on a door that does not open.
5.4 Where credentials belong
Three rules cover it. Use .env for local runs and keep it out of version control. In containers, inject secrets as environment variables from the orchestrator rather than baking them into an image layer, since anyone can walk those back with docker history. Give rotation a defined path: reading once at startup means a new key needs a restart, which batch jobs can absorb, while higher-frequency rotation wants read-on-demand with a short cache.
One more rule trips teams up: do not put tokens in the query string. Query strings end up in your access logs, the other side’s access logs, and every proxy in between, whereas the same token in an Authorization header does not. When you spot ?api_key=, move it.
6. What TypeScript types cannot hold: the Zod runtime contract
This section answers the first failure in the title. Start with the boundary.
6.1 Types constrain your code, not the wire
Declaring interface ProductRow { price?: number } guarantees that your own code will not treat price as a string. It says nothing about:
- whether upstream returns
priceon this call; - whether it arrives as
19.99or"19.99"; - whether
currencybecomingnullmeans the row should not be written at all.
Validation has to happen at runtime, before data reaches your storage. Zod helps here because schema and type share one source: a single schema yields both the static type and the runtime checker, instead of two definitions drifting apart.
import { z } from "zod";
export const ProductSnapshot = z.object({
asin: z.string().regex(/^[A-Z0-9]{10}$/),
marketplace: z.enum(["US", "DE", "JP", "UK"]),
capturedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), // capture date, part of the key
contractVersion: z.string().default("2026-09-01"),
title: z.string().min(1),
brand: z.string().nullish(),
price: z.number().positive().nullish(), // Buy Box price
currency: z.string().length(3).nullish(),
rating: z.number().min(0).max(5).nullish(),
reviewCount: z.number().int().nonnegative().nullish(),
bsrMain: z.number().int().positive().nullish(),
bsrCategory: z.string().nullish(),
});
export type ProductSnapshotT = z.infer<typeof ProductSnapshot>;
Three disciplines follow. Mark nullable fields with nullish() to say “absence is allowed” rather than “always present.” Use the capture date in capturedAt rather than a write timestamp, so reruns on the same day land on the same row. Bump contractVersion when a field’s meaning changes, so old and new definitions coexist instead of overwriting history.
6.2 A hard assertion on P0 fields
Not every field carries equal weight. Pick the ones that make a row unusable when missing and block anything that fails them:
const P0_FIELDS = ["title", "price", "currency"] as const;
function assertP0(row: unknown): row is ProductSnapshotT {
const parsed = ProductSnapshot.safeParse(row);
if (!parsed.success) return false;
return P0_FIELDS.every((f) => parsed.data[f] !== null && parsed.data[f] !== undefined);
}
Note safeParse rather than parse. The latter throws, and inside a Promise.allSettled loop one malformed response turns the whole error path into a single exception that is harder to trace than the datum itself.
6.3 Keep contractVersion in the key
The hardest incidents to reconstruct are not “we got nothing” but “we got something whose meaning moved.” Today’s price is the Buy Box figure, last month it was list price, and upstream does not announce the switch. With the version inside the key, both definitions can live side by side, you read by version when aligning, and history stays intact.
7. Quality gate: why coverage and fill rate are separate numbers
Merge these two into one “success rate” and you lose the ability to see degradation. They have different denominators:
- Coverage: of the ASINs you asked for, how many came back? Denominator is the job list.
- Fill rate: of the records returned, how many passed P0? Denominator is the records returned.
Only separate do they say anything. A reading of coverage 0.98 with fill rate 0.61 means you reached the list but almost two in five records are unusable. Collapsed into one number it reads as “60 percent successful,” which sends you looking at missing coverage when the real cause is a challenge page or absent fields.
export interface GateResult {
coverage: number;
fillRate: number;
usable: number;
blocked: number;
ok: boolean;
}
export function qualityGate(
wanted: ReadonlySet<string>,
rows: Array<{ raw: unknown; asin?: string }>,
minCoverage = 0.98,
minFill = 0.95,
): GateResult {
const got = new Set(rows.map((r) => r.asin).filter(Boolean) as string[]);
const intersected = [...got].filter((a) => wanted.has(a));
const usableRows = rows.filter((r) => assertP0(r.raw));
const coverage = wanted.size === 0 ? 0 : intersected.length / wanted.size;
const fillRate = rows.length === 0 ? 0 : usableRows.length / rows.length;
return {
coverage,
fillRate,
usable: usableRows.length,
blocked: rows.length - usableRows.length,
ok: coverage >= minCoverage && fillRate >= minFill,
};
}
When ok is false, block the batch and write nothing. Half a batch of bad data costs more than no data, because it looks like success and everything downstream consumes it anyway.
7.1 Setting thresholds without guessing
Thresholds belong per scenario, not one value site-wide. Derive them from clean history: take the last two weeks of batches you consider usable, compute the distribution of coverage and fill rate, and set the threshold near the 5th percentile rather than at the mean. Routine batches pass, anomalies get caught.
Orders of magnitude by use case: price monitoring wants tight alignment, coverage at or above 98 percent and P0 fill at or above 95. Competitor reviews track trend rather than exact values, so 95 and 90 hold. New-release and category rankings change shape by nature, where complete retrieval matters more than coverage does. Put the numbers in config and manage them alongside the contract version.
Leave a human path too. When the gate blocks a batch, show which measure failed and what the sample looked like. Without that, someone widens the threshold until the gate means nothing.
8. Pagination, dedupe, idempotency: choosing the key
Get the key wrong and the more elegant your pagination, the more duplicate rows you store. The key has four parts: asin + marketplace + capturedAt + contractVersion. Two locate the object, one locates the moment, one locates the definition.
8.1 Idempotent writes
With SQLite, use a prepared statement inside a transaction:
const upsert = db.prepare(`
INSERT INTO product_snapshot
(asin, marketplace, captured_at, contract_version, title, brand, price, currency, rating, review_count)
VALUES
(@asin, @marketplace, @capturedAt, @contractVersion, @title, @brand, @price, @currency, @rating, @reviewCount)
ON CONFLICT(asin, marketplace, captured_at, contract_version) DO UPDATE SET
title = excluded.title, brand = excluded.brand,
price = excluded.price, currency = excluded.currency,
rating = excluded.rating, review_count = excluded.review_count;
`);
const writeBatch = db.transaction((rows: ProductSnapshotT[]) => {
for (const row of rows) upsert.run(row);
});
writeBatch(usableRows); // reruns on the same day overwrite rather than append
Postgres takes the same idea, swapping the clause for ON CONFLICT (asin, marketplace, captured_at, contract_version) DO UPDATE SET .... The point is that the conflict target must be the composite unique constraint: targeting asin alone makes two runs in one day overwrite history.
8.2 Two traps in pagination
- Page ceilings. Requests past the limit come back as the first page or an empty one, so data grows at the wrong rate and every line in the log reads 200.
- Result sets drift. Fetch the same page twice and the order can differ, which makes pagination a poor way to discover a set.
Drive the job from an ASIN list and use pagination only to fill in detail within it. Where the list comes from and how often it refreshes is its own question, but a job needs a defined set, without exception.
function dedupe<T extends { asin: string; marketplace: string; capturedAt: string }>(
rows: T[],
): T[] {
const seen = new Set<string>();
const out: T[] = [];
for (const r of rows) {
const key = `${r.asin}|${r.marketplace}|${r.capturedAt}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(r);
}
return out;
}
Cross-page repeat rate earns its own alert. Heavy duplication within one job means, in most cases, that the page key failed or upstream started rewinding the cursor, neither of which shows in a single request’s log.
Where the pipeline boundary sits is covered in the Amazon data pipeline piece.
9. Batching and backpressure: what breaks at scale
With a few dozen ASINs, Promise.all(asins.map(...)) works well. By four figures the same line becomes a resource incident.
9.1 The cost of mapping everything at once
Promise.all(asins.map(fetchOne)) issues every request at once. Four thousand ASINs means four thousand in-flight HTTP requests plus four thousand unparsed bodies, so memory gives out before bandwidth does. On failure you also lose the part that landed intact, since a missed batch is either whole or one thrown exception.
9.2 Chunk plus checkpoint
Split the run into blocks, and after each one write to storage and record progress. A restart resumes rather than redoing two hours of work.
async function runBatch(sourceAsins: string[], size = 200) {
for (const [i, group] of chunk(sourceAsins, size).entries()) {
const results = await Promise.allSettled(group.map(fetchOne));
const rows = results
.filter((r): r is PromiseFulfilledResult<Row> => r.status === "fulfilled")
.map((r) => r.value);
const gate = qualityGate(new Set(group), rows);
if (!gate.ok) {
await checkpoint(i, "blocked", gate);
continue; // hold this block for the next round
}
writeBatch(rows);
await checkpoint(i, "ok", gate);
}
}
The checkpoint line carries this design. Dying at block 17 becomes a resumable state rather than a two-hour restart.
9.3 p-limit caps concurrency, not rate
This distinction gets blurred more than any other on this list. p-limit(8) guarantees eight requests in flight at most; it says nothing about rate. The same config yields throughput that differs by orders of magnitude with latency: about 800 req/s when each request returns in 10 ms, about 1.6 req/s when each takes five seconds. Grants upstream are denominated per second, so relying on p-limit alone means exceeding your quota when responses are fast and underusing it when they slow down.
import PQueue from "p-queue";
// A cap on concurrency and a separate cap on rate
const queue = new PQueue({
concurrency: 8, // requests in flight
interval: 1_000, // per one second
intervalCap: 4, // release four at most
});
await queue.addAll(jobs.map((job) => () => fetchOne(job)));
Each parameter answers a different constraint: concurrency matches your connection resources and the tolerance of the far side; intervalCap matches your grant. Set rate first to sit against the grant, then raise concurrency to consume it. Reverse the order and you get high concurrency behind a throttled window, with queue time climbing.
One layer sits below that. When writes cannot keep pace with requests, the queue grows without bound. Node’s strength here — native async, small per-request overhead — is also the trap, since memory climbs with no error until something times out. Give the queue an explicit ceiling and slow production at the limit rather than letting it swell.
10. Graceful shutdown: SIGTERM halfway through
This one matters far more in containers than on a server you manage by hand. docker stop and orchestrated rollouts send SIGTERM, wait, then SIGKILL. Handle nothing and you may end up with a half-written batch, or lose what sat in memory unwritten.
10.1 Three pieces
let shuttingDown = false;
process.on("SIGTERM", () => {
shuttingDown = true;
log.info("SIGTERM received, draining");
});
async function loop(jobs: Job[]) {
for (const job of jobs) {
if (shuttingDown) {
await flush(); // write what arrived but is not stored yet
log.info("stopped cleanly, resume from checkpoint next run");
return;
}
await handle(job);
}
}
The load-bearing piece is flush(): data that cleared the gate but was not yet stored has to land. Combined with the checkpoint above, the next start resumes at the break, and two runs neither skip nor repeat.
10.2 Combining timeouts and signals
Node 17.3 and later gives you AbortSignal.timeout(ms) for a per-request ceiling, and AbortSignal.any([...]) joins a shutdown signal with a timeout into one cancellation source. The gain is that requests in flight get cancelled on shutdown rather than waiting out their own timers.
const stopController = new AbortController();
process.on("SIGTERM", () => stopController.abort());
const res = await fetch(url, {
signal: AbortSignal.any([AbortSignal.timeout(30_000), stopController.signal]),
});
Check the grace window on the deployment side. Orchestrators tend to default to around thirty seconds. If one block averages forty, either shrink the block or extend the window, since a flush that cannot finish in thirty seconds will not finish at all.
11. Structured logs: what you will reconstruct from
Logging tends to degrade into console.log(url), and you find out at incident time that all you have is a wall of 200s. The test is straightforward: can you establish which marketplace, which batch, and which layer failed from logs alone, without a rerun?
11.1 What one record needs
| Field | Purpose |
|---|---|
traceId | Ties every request in a job together to rebuild the scene |
asin / marketplace | Locates the object; logs without these reconstruct nothing |
attempt | Which try succeeded, a variable in the cost formula |
status / errorName | Basis for failure classes instead of guessing from message text |
durationMs | Sample for end-to-end latency, source of p95 |
gate | Gate output, recording why the batch was held |
11.2 A sample worth keeping
Emit JSON, one line per event, through a structured logger such as pino, so anything downstream can aggregate it:
import pino from "pino";
const log = pino({ base: { service: "amazon-collector" } });
log.info({
traceId, asin, marketplace, capturedAt,
attempt: outcome.attempts,
status: outcome.status,
errorName: outcome.error ?? null,
durationMs: Date.now() - startedAt,
gate: gateResult, // { coverage, fillRate, usable, blocked, ok }
});
On disk that lands as one line:
{"level":30,"time":1788940800000,"service":"amazon-collector",
"traceId":"f47ac10b","asin":"B08N5WRWNW","marketplace":"DE",
"capturedAt":"2026-09-09","attempt":2,"status":200,"errorName":null,
"durationMs":1843,"gate":{"coverage":0.98,"fillRate":0.61,
"usable":610,"blocked":390,"ok":false}}
That trailing gate.ok: false is the reason to log this way. Three weeks later, when someone asks why German prices stopped lining up, that one line shows sixty-one percent of the batch cleared P0 checks, and you skip the guesswork.
11.3 Deriving the numbers back out
With those fields present, quality and cost come from the same source rather than a second system:
-- Last seven days: quality and retry cost per day
SELECT captured_at,
AVG(attempt)::numeric(4,2) AS avg_attempts,
COUNT(*) FILTER (WHERE status = 200) * 1.0 / COUNT(*) AS http_ok_rate,
AVG((gate->>'fillRate')::float) AS fill_rate
FROM collector_log
WHERE captured_at >= current_date - 7
GROUP BY captured_at
ORDER BY captured_at;
Three curves are enough. Rising avg_attempts means upstream is unstable or you are hitting a wall. http_ok_rate alone carries little meaning, since challenge pages return 200 too. Falling fill_rate points at the field contract or the render layer most of the time. All three moving together means, in most cases, that the exit or fingerprint layer got classified.
12. Cost instrumentation: per 1,000 usable records
Instrument this yourself, because the numerator lives with the vendor and the denominator only exists in your logs.
cost per 1,000 usable records = 1000 x unit price x mean attempts x (1 + render multiple)
──────────────────────────────────────────────────────────
success rate x fill rate x (1 - block rate)
+ proxy traffic per 1,000 + upkeep amortized per 1,000
Take those three denominators from your own logs rather than a vendor average, since published figures do not separate your failure rate from your block rate. Proxy traffic bills by the gigabyte and failed requests count too. Upkeep is the line most often omitted: one escalation on the defense side tends to cost two or three engineer-days.
class MeteredClient extends AmazonClient {
credits = 0;
attempts = 0;
override async fetch<T>(kind: EndpointKind, params: Record<string, string>) {
const res = await super.fetch<T>(kind, params);
this.credits += 1;
this.attempts += res.attempts;
return res;
}
costPer1kUsable(usable: number, unitPrice: number): number {
if (usable === 0) return Infinity;
return (this.credits * unitPrice * 1000) / usable;
}
}
Turning a quote into a real bill is its own exercise: the Amazon data API pricing breakdown takes the six billing units apart for comparison.
13. Four metrics, two alerts
Four metrics are enough. More than that and nobody reads them.
| Metric | Means | Why it stands alone |
|---|---|---|
| Coverage | Share of the list that returned | Separates “not retrieved” from “retrieved but unusable” |
| P0 fill rate | Share of returned rows that cleared validation | Fetched, yet not usable |
| p95 end-to-end latency | Single record from request to storage | Degrades before any timeout fires |
| Cost per 1,000 usable records | Output of the formula above | The only figure tying invoices to business volume |
Two alerts then cover the rest: coverage or P0 fill below threshold for two runs in a row, which is a data incident; and cost per 1,000 usable records up more than 30 percent week over week, which is a budget incident. Both appear in logs well before anyone files a complaint.
14. Production deployment: restart semantics of PM2, Docker, and cron
The runner decides how failure gets handled, and that choice gets less thought than it deserves.
| How it runs | Behavior after failure | Watch for |
|---|---|---|
cron starting a process | Retries only at the next tick, nothing noticed meanwhile | Need your own lock, or runs overlap |
| PM2 with an internal timer | Restarts after a crash | Restart wipes memory state, so checkpoints must persist |
| Docker under an orchestrator | SIGTERM, then force kill after grace | Grace period must exceed one block’s runtime |
| Serverless on a schedule | Hard ceiling on execution time | Chunk to fit the cap rather than raising concurrency |
One rule holds across all four: write “last completed block” to storage outside the process. Progress held in memory resets on restart, which looks like every crash restarting from scratch, doubling invoices without adding data.
15. What you do not have to build
By this point the work that belongs to you comes down to three things: define the field contract, define the quality thresholds, define what the data is for. Everything else exists because clean data does not arrive without infrastructure.
| Layer | What self-hosting requires | On the Pangolinfo side |
|---|---|---|
| TLS and HTTP/2 fingerprints | Pick a client, pin profiles, track browser releases | Included, updated as browsers change |
| Browser fingerprint coherence | Maintain device profiles, align renderers | Included |
| Residential and mobile exits | Buy traffic, run the pool, watch hygiene and decay | Included, no separate line item |
| JavaScript rendering | Run a browser fleet, or work out which fields need it | Done per endpoint server-side, no render multiple |
| Challenges and CAPTCHAs | Detect the page, rotate exits, retry | Handled plus retried on our side |
| Geo and postal-code alignment | Stand up exit nodes per marketplace | Included |
Put another way, one request covers everything. The Pangolinfo Amazon Scraper API keeps those layers server-side, with residential IPs, fingerprint impersonation, JavaScript rendering, and challenge handling inside the price rather than stacked as surcharges. Your side stays a plain fetch, minus the sixth-through-tenth sections above, keeping the P0 contract, the quality gate, and idempotent writes.
Run the structure against a free tier first: the first 60 requests after registration are free, with no card required. Take twenty real ASINs through the request layer and quality gate, then decide whether to carry the fingerprint and IP layers yourself.
See pricing and billing units · Read the integration docs · Open the console
16. Amazon Data API Node.js: checklist before you ship
Five lines. Missing any one of them costs you rework around week three:
- timeouts set per phase — connect, headers, body — rather than one value for all three;
- one runtime assertion on P0 fields, not only TypeScript types;
- a primary key holding capture date and contract version, with writes as upsert;
- failures split into transient, throttled, challenged, and contract rather than one
catch; - count and cost recorded per call, so cost per 1,000 usable records is computable.
The earlier sections cover getting data; these cover getting the same data to line up day after day. The distance between them is the distance between a job that finishes and a job you can leave alone.
If you are still choosing a route, the Amazon data API overview sets out authorization boundaries and cost structures across five routes, and the cost crossover between building and calling covers where self-hosting stops paying.
17. Questions teams ask
Can Node’s built-in fetch call the Amazon Data API on its own?
It sends fine, but two layers need your attention. Node’s fetch does not negotiate HTTP/2 by default, so you pass an agent with allowH2; and its TLS handshake comes from Node’s OpenSSL, which no browser fingerprint matches, so protected surfaces will classify it. Fine for a prototype, one layer short for a daily job.
I changed the User-Agent and the proxy. Still blocked. Why?
Two candidates cover nine cases out of ten. Either the TLS and HTTP/2 fingerprint is still Node’s own, or your signals contradict each other, a Windows User-Agent with an HTTP/2 window shaped like Linux, or a German IP sending en-US. Call a public fingerprint endpoint to confirm JA4, then check that exit country, storefront, and language share one source.
Should I turn on undici’s allowH2?
It depends on the endpoint. Once HTTP/2 is live the concurrency model shifts from connections to streams per connection, bound by maxConcurrentStreams and the flow window, so raising app-level concurrency may not move throughput. For batch work, turn it on, set pool size first, then the queue.
With TypeScript types in place, do I still need Zod?
Yes. Types constrain your own code at compile time and say nothing about whether a field arrived, or whether it arrived as a number rather than a string. Without a runtime check, upstream switching price to a string goes unnoticed while downstream reports drift in silence.
How much concurrency, and how do p-limit and the pool fit together?
Two layers. p-limit or a queue caps how many requests your code issues; the pool and HTTP/2 settings decide how those land on connections. Hold app concurrency steady while you tune connections, watch the 429 share and end-to-end latency, and step back once throughput flattens.
If a job dies and I rerun, do I get duplicate rows?
That depends on the key and the write. Include asin, marketplace, capture date, and contract version in the key, then write with ON CONFLICT DO UPDATE rather than appending. Add a checkpoint after each chunk and a rerun overwrites the same day’s rows while resuming where it stopped.
How should Node errors be classified for retry?
Transient failures (connect or body timeout, DNS failure) and throttling (429, or 503 with Retry-After) warrant retries. A 403 or challenge page does not. A 200 with missing fields is a contract breach, and retrying returns the same empty field each time. Parse failures mean, in most cases, that an HTML challenge page came back.
Anything specific about Docker or Kubernetes here?
Two things. Handle graceful shutdown, since the orchestrator sends SIGTERM and gives you a window in which to flush what you already fetched. And store “which chunk finished” outside the process, because in-memory progress resets on restart, so each crash restarts the job and doubles spend without adding data.
Does Pangolinfo’s pricing include residential IPs, or do I buy proxies on the side?
Included, and not just the residential IPs. One request covers everything: residential and mobile exits with rotation, TLS and HTTP/2 fingerprints, browser fingerprint coherence, JavaScript rendering where an endpoint needs it, and server-side handling of challenges and retries, none billed as a separate line. You send one request and receive one structured, real-time JSON.
Are fingerprints, JavaScript rendering, and CAPTCHA handling billed as add-ons?
No. Fingerprint impersonation, JavaScript rendering where an endpoint needs it, and server-side handling of challenge pages and retries all sit inside the billing unit for one request. There is no render multiple, no per-endpoint difficulty multiplier, and no residential surcharge. That is the structural difference against vendors who price by stacked add-ons.
We track forty ASINs. Is any of this worth it?
Three parts of it: timeouts and retries in the request layer, P0 field checks, and a snapshot table keyed by date. Around sixty lines, and in exchange you can rerun after a failure and draw a price curve three months later. Leave rate limiting until you reach four figures.
