Jake Baker

Batch API processing with rate limiting and per-item error tracking

30 June 2025

A market operator uploads a spreadsheet with 500 guest records. The API rate-limits at 100 requests per second. If I fire all 500 at once, most of them bounce. If I send them one at a time, it takes minutes. And if one record fails, the other 499 shouldn’t care.

I built this for a trade show platform where international market operators each managed their own participant lists. The import needed to handle hundreds of records per upload, respect rate limits, and give the operator a clear summary of what worked and what didn’t.

The approach: slice the array into batches of 50, process each batch concurrently with Promise.all, then wait before starting the next batch.

export default defineEventHandler(async (event) => {
  const items = await readBody(event)
  const results = []
  const batchSize = 50
  const rateLimit = 100
  const delayBetweenBatches = Math.ceil(1000 * batchSize / rateLimit)

  const meta = { total: 0, successful: 0, failed: 0 }

  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize)

    const batchResults = await Promise.all(
      batch.map(async (item) => {
        try {
          const response = await createRecord(item)
          meta.successful++
          return { payload: item, success: true, result: response }
        } catch (error) {
          meta.failed++
          return {
            payload: item,
            success: false,
            error: { message: error.message, data: error.data, status: error.status },
          }
        }
      }),
    )

    results.push(...batchResults)

    // Delay before next batch to stay within rate limit
    if (i + batchSize < items.length) {
      await new Promise(resolve => setTimeout(resolve, delayBetweenBatches))
    }
  }

  meta.total = results.length
  return { results, meta }
})

A few things about this that are worth calling out.

The delay calculation is derived from the rate limit, not hardcoded. Math.ceil(1000 * 50 / 100) gives 500ms between batches of 50 — enough to stay under 100 requests per second. If the rate limit changes, I change one number instead of guessing at a sleep duration.

Each item gets its own try/catch. This is the important part. If record #47 has an invalid email, the other 49 records in that batch still process. The failed record returns with its original payload, the error message, and the validation errors from the API. The operator sees exactly which rows failed and why.

The meta object tracks counts in real time. By the end, meta has the total, successful, and failed counts without a separate counting pass over the results array.

No retry logic. I considered retrying failed records, but for this use case the failures were almost always data quality issues (missing required fields, duplicate emails), not transient network errors. Retrying bad data just wastes requests. The operator fixes the spreadsheet and re-imports the failed rows.

The response shape matters for the frontend. Each result carries the original payload alongside the outcome. The import summary component can show the operator: “412 imported, 88 failed” with a table of failed rows showing the row data and the specific error for each one. The operator doesn’t need to guess which rows had problems — they can see the payload they submitted alongside the error.

One edge case: if every single record fails, the handler throws a 422 instead of returning a 200 with all failures. This prevents the frontend from treating a completely botched import as a success just because the HTTP request itself completed.

if (results.every(result => !result.success)) {
  throw createError({
    statusCode: 422,
    statusMessage: 'All records failed',
    data: { results, meta },
  })
}

The pattern is simple — batch, wait, track — but the per-item error tracking is what makes partial failures recoverable instead of catastrophic.