Home / Blog / Amazon Product API in GraphQL: Deduping Repeated ASINs with DataLoader

Amazon Product API in GraphQL: Deduping Repeated ASINs with DataLoader

Wrap the TypeScript quickstart's unmodified client.js and retry.js in a graphql-js schema, and a GraphQL-specific billing trap appears that a plain REST client never exposes: a query can ask for the same ASIN twice. A per-request DataLoader, never a cross-request cache, makes that exact duplicate free without pretending the API has a batch endpoint it does not have.

November 12, 2026
7 min read
SellerMagnet Team
Share & Bookmark
Terminal showing a graphql-js resolver calling a per-request DataLoader for an Amazon ASIN

Wrapping an Amazon product API in GraphQL creates one problem a plain REST client never has: a single query can ask for the same ASIN twice, through a duplicate list entry or two aliased fields, and a naive resolver bills it twice. Fix it with a dataloader instance per endpoint, created fresh for every incoming GraphQL request, wrapping the TypeScript quickstart's own client.js/retry.js/lookup.js/offers.js unchanged.

Key Takeaways

  • GraphQL lets a client ask for the same ASIN twice in one request (a duplicate list entry, or two aliased fields); a naive resolver-per-field server bills that ASIN twice, a mistake a plain REST client never exposes because it only makes the calls it writes.
  • There is still no batch endpoint behind product lookup or offers: DataLoader here cannot turn two DIFFERENT ASINs into one HTTP call, only an exact duplicate KEY into one call, replayed to every caller that asked for it in the same request.
  • A DataLoader instance must be created fresh per GraphQL request, never hoisted to a module-level singleton: its own documentation calls the cache a per-request memoization cache, and warns that reusing it across requests can serve one caller's result to another.
  • Billing-aware retry is unchanged from every other quickstart in this series: free 429/401/403/gate-400, a 500 without Retry-After refunded and retried once, 502/503 billed and capped at 4 attempts, 404 billed and never retried.
  • A 404 on one ASIN in a list query surfaces as one entry in the response's errors array; every other field in the same request still resolves normally.

Why does GraphQL let you pay twice for the same Amazon ASIN?

Because GraphQL resolves each requested field independently. A REST client calling /amazon-product-lookup directly only ever makes the calls its own code writes, so asking for the same ASIN twice means writing that call twice, something nobody does by accident. A GraphQL schema built from a list argument (products(asins: [String!]!)) or from two differently aliased fields resolves one request per item or per alias; if the list has a repeated ASIN, or two aliases point at the same ASIN, the naive version calls the lookup endpoint once per occurrence, not once per unique ASIN, and SellerMagnet has no batch endpoint to absorb the difference: each call is its own credit.

Calls made per scenario, naive resolver vs. a per-request DataLoader (tested, see output below).
Query shapeWhat it asks forNaive callsWith DataLoaderCredits saved
products([A, A])duplicate ASIN in one list2 calls1 call1 saved
a: products([A]) b: products([A])same ASIN, two aliased fields2 calls1 call1 saved
products([A]) + offers([A])same ASIN, two different endpoints2 calls2 calls0 (by design)
products([A, B])two different ASINs2 calls2 calls0 (no batch endpoint exists)

Reusing the TypeScript quickstart's client and retry logic

client.js, retry.js, types.js, lookup.js and offers.js are the exact files from the TypeScript quickstart, unedited. The only new code is the GraphQL layer: a schema with two root fields and a loaders.js module that wires each one to a DataLoader.

client.js - unmodified from the TypeScript quickstart

// client.js - one fetch-based client, X-Api-Key header, 120 s timeout.

// Overridable only for pointing the client at a local test server; a real
// program never needs to set SELLERMAGNET_API_BASE.
const API_BASE = process.env.SELLERMAGNET_API_BASE ?? "https://sellermagnet-api.com/api/";

/**
 * @typedef {Object} ApiErrorShape
 * @property {number} status
 * @property {string} message
 * @property {number} retryAfter - seconds, 0 when the header was absent.
 */

/** Thrown for any non-2xx response or a failed JSON decode. */
export class ApiError extends Error {
  /** @param {ApiErrorShape} shape */
  constructor({ status, message, retryAfter }) {
    super(`${status}: ${message}`);
    this.name = "ApiError";
    this.status = status;
    this.retryAfter = retryAfter;
  }
}

/**
 * Send one GET to the API and return the decoded `data` field.
 * @template T
 * @param {string} endpoint - e.g. "amazon-product-lookup"
 * @param {Record<string, string>} params
 * @returns {Promise<T>}
 */
export async function get(endpoint, params) {
  const url = new URL(API_BASE + endpoint);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);

  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 120_000); // server allows up to ~120 s
  let response;
  try {
    response = await fetch(url, {
      headers: { "X-Api-Key": process.env.SELLERMAGNET_API_KEY ?? "" },
      signal: controller.signal,
    });
  } finally {
    clearTimeout(timeout);
  }

  const retryAfter = Number.parseInt(response.headers.get("retry-after") ?? "0", 10) || 0;
  let body;
  try {
    body = await response.json(); // a proxy's error page is HTML, not JSON
  } catch {
    throw new ApiError({ status: response.status, message: response.statusText, retryAfter });
  }
  if (response.ok && body.success) return body.data;
  throw new ApiError({ status: response.status, message: body.message ?? response.statusText, retryAfter });
}

retry.js - unmodified from the TypeScript quickstart

// retry.js - retries that respect billing: 429/500(once)/502/503 only.

import { ApiError } from "./client.js";

const RETRYABLE = new Set([429, 500, 502, 503]);

/**
 * @template T
 * @param {() => Promise<T>} call
 * @param {number} [attempts]
 * @returns {Promise<T>}
 */
export async function withRetry(call, attempts = 4) {
  let seen500 = false;
  for (let attempt = 1; ; attempt++) {
    try {
      return await call();
    } catch (err) {
      if (!(err instanceof ApiError)) throw err;
      const give500Twice = err.status === 500 && seen500;
      if (!RETRYABLE.has(err.status) || give500Twice || attempt === attempts) throw err;
      seen500 = seen500 || err.status === 500;
      const backoff = 2 ** attempt * 100; // ms, small for the test harness
      const wait = Math.max(backoff, err.retryAfter * 1000);
      await new Promise((resolve) => setTimeout(resolve, wait));
    }
  }
}

Wiring a DataLoader per request, not per server

loaders.js exports createLoaders(), which must be called once per incoming GraphQL request and placed in that request's contextValue, never created once at server start. DataLoader's own documentation is explicit about why: its cache is a per-request memoization cache, and "avoid multiple requests from different users using the DataLoader instance, which could result in cached data incorrectly appearing in each request" (github.com/graphql/dataloader). A loader kept alive across requests would serve a later caller the price an earlier caller already paid for and already saw, with no way to refresh it.

loaders.js - one DataLoader per endpoint, created fresh per request

// loaders.js - one DataLoader per endpoint, created fresh for every GraphQL
// request. There is no batch endpoint: product lookup takes one ASIN per
// request, so the batch function below cannot turn N different
// ASINs into one HTTP call; it still calls the real endpoint once per
// UNIQUE key and replays the same settled promise to every duplicate
// .load() call in this request, so an exact duplicate key (a repeated ASIN
// in a list, or the same ASIN behind two aliased fields) costs one credit,
// not two.
import DataLoader from "dataloader";
import { withRetry } from "./retry.js";
import { lookupProduct } from "./lookup.js";
import { getOffers } from "./offers.js";

/**
 * @param {string} asin
 * @param {string} marketplaceId
 * @returns {string} a stable per-(asin, marketplace) cache key.
 */
function keyOf(asin, marketplaceId) {
  return `${asin}|${marketplaceId}`;
}

/**
 * Build one request's loader set. MUST be called fresh per incoming GraphQL
 * request and passed in as part of `contextValue` - never hoisted to a
 * module-level singleton. DataLoader's own cache is a request-scoped
 * memoization cache, not a cross-request price cache (verified live on
 * github.com/graphql/dataloader and graphql-js.org/docs/n1-dataloader): a
 * loader kept alive across requests would serve a caller the price another,
 * earlier caller already paid for and already saw, with no way to refresh.
 * @returns {{products: DataLoader<string, any>, offers: DataLoader<string, any>}}
 */
export function createLoaders() {
  // DataLoader hands the batch function every key collected within one
  // event-loop tick. We still call the API once per unique key (no batch
  // endpoint exists to combine them into one request); duplicate keys in
  // the same tick are what DataLoader dedupes for us, via its default
  // cache. Keys are parsed back into (asin, marketplaceId) pairs.
  const products = new DataLoader(async (keys) => {
    return Promise.all(
      keys.map(async (k) => {
        const [asin, marketplaceId] = k.split("|");
        try {
          return await withRetry(() => lookupProduct(asin, marketplaceId));
        } catch (err) {
          return err; // graphql-js re-throws a key's own Error into that field only
        }
      }),
    );
  });

  const offers = new DataLoader(async (keys) => {
    return Promise.all(
      keys.map(async (k) => {
        const [asin, marketplaceId] = k.split("|");
        try {
          return await withRetry(() => getOffers(asin, marketplaceId));
        } catch (err) {
          return err;
        }
      }),
    );
  });

  return {
    loadProduct: (asin, marketplaceId) => products.load(keyOf(asin, marketplaceId)),
    loadOffers: (asin, marketplaceId) => offers.load(keyOf(asin, marketplaceId)),
  };
}

schema.js - the GraphQL schema; resolvers call loaders, never the client modules directly

// schema.js - a small graphql-js schema in front of the lookup/offers
// endpoints. Each field resolver calls the matching DataLoader from
// context, never the client modules directly, so duplicate keys in one
// request are deduped. marketplaceId defaults to the US marketplace.

import {
  GraphQLSchema,
  GraphQLObjectType,
  GraphQLList,
  GraphQLNonNull,
  GraphQLString,
  GraphQLFloat,
} from "graphql";

const US = "ATVPDKIKX0DER";

const BuyBoxInfo = new GraphQLObjectType({
  name: "BuyBoxInfo",
  fields: {
    price: { type: GraphQLFloat }, // null when the API sent "N/A"
    currencyCode: { type: GraphQLString },
    sellerId: { type: GraphQLString },
  },
});

const Product = new GraphQLObjectType({
  name: "Product",
  fields: {
    asin: { type: GraphQLString },
    title: { type: GraphQLString },
    link: { type: GraphQLString },
    buyBoxInfo: { type: BuyBoxInfo },
  },
});

const Offer = new GraphQLObjectType({
  name: "Offer",
  fields: {
    sellerId: { type: GraphQLString },
    sellerName: { type: GraphQLString },
    condition: { type: GraphQLString },
    fulfillmentType: { type: GraphQLString },
    totalPrice: { type: GraphQLFloat },
  },
});

const OffersResult = new GraphQLObjectType({
  name: "OffersResult",
  fields: {
    asin: { type: GraphQLString },
    buyBox: { type: Offer },
    offers: { type: new GraphQLList(Offer) },
  },
});

// Neither /amazon-product-lookup nor /amazon-product-offers validates the
// ASIN format server-side: a malformed ASIN is sent upstream and billed like any other
// request. Validating it here, before any loader call, saves the credit.
const ASIN_RE = /^[A-Z0-9]{10}$/;

function checkedLoad(asin, load) {
  if (!ASIN_RE.test(asin)) {
    return Promise.reject(new Error(`"${asin}" is not a 10-character ASIN - skipped, no credit spent`));
  }
  return load();
}

const Query = new GraphQLObjectType({
  name: "Query",
  fields: {
    products: {
      type: new GraphQLList(Product),
      args: { asins: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString))) } },
      // one resolver call per list ITEM (graphql-js does not batch list args
      // for you); dedup across these calls is exactly what the loader does.
      resolve: (_src, { asins }, ctx) =>
        asins.map((asin) => checkedLoad(asin, () => ctx.loaders.loadProduct(asin, US))),
    },
    offers: {
      type: new GraphQLList(OffersResult),
      args: { asins: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLString))) } },
      resolve: (_src, { asins }, ctx) =>
        asins.map((asin) => checkedLoad(asin, () => ctx.loaders.loadOffers(asin, US))),
    },
  },
});

export const schema = new GraphQLSchema({ query: Query });

Note what ASIN_RE in schema.js is really for: neither /amazon-product-lookup nor /amazon-product-offers validates the ASIN format on SellerMagnet's own side, so a malformed ASIN sent through either endpoint is billed exactly like a valid one. The regex check here is entirely this schema's own guard, run before any loader call, not a status the API itself would ever return.

Table comparing calls made by a naive GraphQL resolver versus one wired to a per-request DataLoader for repeated ASINs
Only an exact duplicate key inside one request gets deduped; different ASINs always cost their own call.

What happens to a 404 in the middle of a GraphQL list query?

It becomes one entry in the GraphQL response's errors array, with that list position's data set to null; every sibling field in the same request still resolves. A 404 is a real, billed answer (SellerMagnet already charged the credit before deciding the product does not exist on that marketplace), so it is never retried, exactly like every other quickstart in this series.

Status codes, billing and the resolver's reaction (API behaviour checked live, Nov 2026; the first row is this schema's own client-side guard, not a status the API returns).
StatusMeaningBilledResolver action
200SuccessYesUse data, dedupe per request
(client-side check)ASIN regex in schema.js, before any call - not a real API statusNoFix the input
400 (gate)Marketplace not coveredNoFix the call
401 / 403Key or creditsNoStop
404Not found on this marketplaceYesPer-field GraphQL error, no retry
429Concurrency capNoWait Retry-After
500Internal errorNo, refundedRetry once
502 / 503Upstream / breakerYesWait, then retry, capped at 4
Sequence diagram of a GraphQL query with a duplicate ASIN collapsing into one SellerMagnet call through a DataLoader
Two resolver calls for the same key become one call; DataLoader replays the result to both.

Does DataLoader batch different ASINs into one SellerMagnet call?

No, and the post says this plainly so it is never assumed otherwise: there is no batch endpoint behind /amazon-product-lookup or /amazon-product-offers, so the batchLoadFn passed to each DataLoader still calls the real endpoint once per unique key, it just avoids calling it again for a key already requested in the same tick. A typical SQL-backed DataLoader turns ten different ids into one WHERE id IN (...) query; this one cannot, because SellerMagnet has nothing on the other end to receive ten ASINs at once.

A query with a repeated ASIN that this schema deduplicates to one call

query {
  products(asins: ["B0CL61F39H", "B0CL61F39H", "B0SELLERB0"]) {
    asin
    title
    buyBoxInfo { price sellerId }
  }
  offers(asins: ["B0CL61F39H"]) {
    asin
    buyBox { totalPrice fulfillmentType }
  }
}

B0CL61F39H is a real, documented ASIN (the same PS5 console example used throughout this series); B0SELLERB0 is an illustrative placeholder standing in for a second, unrelated product, used only to show that a genuinely different ASIN never gets deduped.

Real output from the tested harness (graphql-js + dataloader npm packages, fakefetch.mjs, illustrative data)

{
  "data": {
    "products": [
      {
        "asin": "B0CL61F39H",
        "title": "PlayStation(R)5 console (slim)",
        "buyBoxInfo": {
          "price": 444.99,
          "sellerId": "Amazon"
        }
      },
      {
        "asin": "B0CL61F39H",
        "title": "PlayStation(R)5 console (slim)",
        "buyBoxInfo": {
          "price": 444.99,
          "sellerId": "Amazon"
        }
      },
      {
        "asin": "B0SELLERB0",
        "title": "USB-C Cable 2m",
        "buyBoxInfo": {
          "price": 11.49,
          "sellerId": "A2Q3Y263SELLER"
        }
      }
    ],
    "offers": [
      {
        "asin": "B0CL61F39H",
        "buyBox": {
          "totalPrice": 444.99,
          "fulfillmentType": "FBA"
        }
      }
    ]
  }
}

3 credits charged (3 unique product/offer lookups: B0CL61F39H lookup, B0SELLERB0 lookup, B0CL61F39H offers; the repeated B0CL61F39H in the products list cost 0 extra credits)

Frequently Asked Questions

Does a GraphQL schema let me combine many different ASINs into one SellerMagnet call?

No. There is no batch endpoint behind product lookup or offers; each unique ASIN still costs its own request and its own credit. DataLoader only removes the cost of asking for the exact SAME ASIN more than once inside a single GraphQL request.

Why would a GraphQL query ever ask for the same ASIN twice?

A product list the client built from two data sources can repeat an ASIN, or a query can alias the same field twice to fetch it under two names. A plain REST client calling the endpoint directly never hits this, because it only makes the calls it writes; GraphQL's field-level resolvers make it easy to ask for the same thing twice by accident.

Is DataLoader's cache safe to keep alive across multiple GraphQL requests?

No. DataLoader's own documentation calls it a per-request memoization cache, not an application cache, and warns that reusing one instance across requests can serve one caller's result to another. A fresh DataLoader set is created for every incoming request here.

What happens to the rest of a GraphQL query when one ASIN returns a 404?

Only that field resolves to an error; graphql-js still returns data for every other field in the same request. The 404 is billed and is never retried, matching the REST behaviour exactly, just surfaced as one entry in the response's errors array instead of a failed HTTP call.

Do I need the real dataloader npm package, or can I write a plain object cache instead?

A plain Map works for exact-key dedup inside one synchronous resolver call, but DataLoader also coalesces calls made across the same event-loop tick from unrelated resolvers (for example two different list items), which a Map alone will not do without extra bookkeeping.

Bottom line: a GraphQL wrapper for an Amazon product API needs exactly one new piece beyond the TypeScript quickstart's unmodified client, a DataLoader per endpoint created fresh for every request, so the one mistake GraphQL makes easy, asking for the same ASIN twice in one query, costs one credit instead of two, without ever pretending the underlying API batches different ASINs together. Every endpoint is documented on the API documentation page, more runnable snippets live on the code examples page, and a free account includes 150 credits to try it.

Related Articles

Continue exploring topics related to this article.

Back to Blog

Ready to Extract Amazon Data at Scale?

Start building with SellerMagnet API today. Real-time product data, competitive pricing, and review analytics at your fingertips.

150 free API credits • No credit card required • Cancel anytime