# How to batch writes without losing per-item error detail

> Answer a batch write with one result per input, keyed by the client's id, under an errors flag, so a client retries only what did not land.

Source: https://voxgig.com/howto/batch-writes-without-losing-per-item-errors

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-17

## Short answer

Return one result per input item, in input order. Each carries the id the client sent, an HTTP status of its own, and either the created resource's location or an RFC 9457 problem. Put an errors flag at the top so a client knows whether to read on. Keep the batch non-atomic by default, offer atomic as a request option, and have the client resend only the items the server did not attempt, by id.

---
## You will need

Node 22 or later, an endpoint that creates one item, and an error format for a single failure,
here [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html) problem details. Verified 2026-10-21
against Node 22.22.0. The sample is a [`node:http`](https://nodejs.org/api/http.html) server and a
`fetch` client in one process, with tests on the [Node test runner](https://nodejs.org/api/test.html),
and it installs no package. The store is a `Map`, which keeps the batch semantics visible and the
database out of the way.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [DynamoDB BatchWriteItem and TransactWriteItems](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_BatchWriteItem.html) | A store that offers both shapes, so the caller picks per request: 25 unordered puts, or up to 100 actions as one transaction | Two APIs to learn, an `UnprocessedItems` loop to write for the batch, and a transaction that rejects everything on one bad item | You own the endpoint and want one shape callers cannot get wrong |
| [Elasticsearch `_bulk` with the errors flag](https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-bulk) | Clients built for the API, which read `errors` and the per-item `status` by habit | A 200 on a batch where nothing was written, so a client that stops at the status code learns nothing | Callers you do not control, who check `res.ok` and move on |
| [HTTP 207 Multi-Status](https://www.rfc-editor.org/rfc/rfc4918.html#section-13) | Independent writes where the status code itself should say that the body needs reading | A WebDAV code whose specified body is XML, so the JSON body is yours to define, and a 2xx that still needs parsing | A store that cannot leave a batch half written |
| [JSON:API Atomic Operations](https://jsonapi.org/ext/atomic/) | Related resources created together, where a failure must undo everything before it | All or nothing on every request, a media type parameter on every call, and an extension clients must know | Independent items in large batches, where one bad row would send the rest back |
| [Microsoft Graph JSON batching](https://learn.microsoft.com/en-us/graph/json-batching) | Requests of different kinds in one call, each with its own id and status, up to 20 at a time | A 200 envelope that says nothing about the requests inside, and dependents that fail with 424 when their parent does | A single resource type at volume, where 20 a call is the wrong unit |

The two families differ in what a client does after a failure. Under partial success, the client
reads the results, fixes the failed items, sends the unattempted ones again, and the rest stay
written. The
cost is a body every client must parse, which Elasticsearch and Graph leave to the reader.[^1]
Under all or nothing, one bad item sends the whole batch back, which JSON:API requires and
DynamoDB offers as a transaction. The cost is throughput: a batch of fifty with one bad row lands
no rows until somebody fixes it.

## Shape one result per input, in input order

The handler validates each item on its own, writes the good ones, and answers in the order it was
asked, so position and id both identify a result. Every result carries the client's id, an HTTP
status for that item, and either a `location` or a problem.

```js title="server.mjs"
  // Partial success: every item gets the outcome the same write would earn on its own.
  function partial(items) {
    let attempted = 0
    return items.map((item) => {
      const bad = validate(item) ?? conflict(item)
      if (bad) return { id: typeof item?.id === 'string' ? item.id : null, status: bad.status, problem: bad }
      if (store.has(item.id)) return exists(item)
      if (attempted >= budget) return notAttempted(item)
      attempted++
      return write(item)
    })
  }
```

The per-item status is the one the same write would earn as a single request. A new row earns
201, a replay of a stored item earns 200, and an item that needs fixing earns 422 with a problem.
A stored id sent with different content earns 409, because a 200 would tell the client that its
changed content landed. The last outcome, 503 with a `not-attempted` problem, is DynamoDB's
`UnprocessedItems` in HTTP clothing: the server ran out of budget for this request and wants the item back unchanged. A
client has to tell that apart from a 422, because one is resent as it is and the other is not
resent until a person changes it.

```js title="server.mjs"
    const items = body?.items
    if (!Array.isArray(items) || items.length === 0) return refuse(res, problem('items-required', 'items must be a non-empty array', 422))
    if (items.length > cap) return refuse(res, problem('batch-too-large', `A batch holds at most ${cap} items`, 422, { max: cap, received: items.length }))
    const repeats = repeatedIds(items)
    if (repeats.length) return refuse(res, problem('repeated-id', 'An id appears more than once in this batch', 422, { repeats }))
    const results = body.atomic === true ? atomic(items) : partial(items)
    send(res, statusFor(results), { errors: results.some((r) => r.status >= 400), results })
```

Three things refuse the whole request before any item is looked at: a missing or empty array,
more items than the cap, and an id that appears twice. The repeated id is refused rather than
reported per item because a result keyed by that id would be ambiguous, which is the rule
Graph applies with a 400.[^2] Everything else is answered item by item, under an `errors` flag
that says whether anything is not 2xx.

```js title="server.mjs"
/** 200 when every item landed, 207 when some did, otherwise the one status that fits them all. */
export function statusFor(results) {
  const landed = results.filter((r) => r.status < 300).length
  if (landed === results.length) return 200
  if (landed > 0) return 207
  return results.every((r) => r.status === 503) ? 503 : 422
}
```

The batch status is derived from the items, the rule the page on
[reporting partial failure](/howto/report-partial-failure-in-a-batch-endpoint) argues at length.
It is 200 when every item landed, 207 when some did, and a 4xx or 5xx when none did. 207 is in the 2xx
class, so `res.ok` is true for it,[^3] and the flag exists for the client that reads nothing else.

```bash
node demo.mjs
```

```text output
a batch of six: four new, one invalid, one already stored, to a store that writes 3 a request
  POST /items/batch -> 207, errors true, 6 items
  POST /items/batch -> 200, errors false, 1 item
  ln_a  201  /items/it_2
  ln_b  422  qty must be a positive integer (item ln_b sent qty -2)
  ln_c  201  /items/it_3
  ln_d  200  /items/it_1
  ln_e  201  /items/it_4
  ln_f  201  /items/it_5
  created 4, already stored 1, failed 1, not attempted 0
  came back 503 and was resent unchanged: ln_f
```

Six inputs, six results in input order. `ln_d` was stored before the batch and answers 200 with
the location it already had, so a replay costs no second row. `ln_f` fell past the budget of
three writes, came back 503, and landed on the second request without anyone touching it.

## Resend only what did not land

The client chunks to the cap, reads every result, and sorts them into bins by status.

```js title="client.mjs"
/** Every item sent gets one result under its own id. A repeated or invented id hides a lost item. */
function checkIds(chunk, results) {
  // The server answers an item without a string id under null, so null stands in for it.
  const missing = chunk.map((item) => (typeof item?.id === 'string' ? item.id : null))
  const unexpected = []
  for (const result of results) {
    const at = missing.indexOf(result?.id)
    if (at === -1) unexpected.push(result?.id)
    else missing.splice(at, 1)
  }
  if (missing.length || unexpected.length) {
    const ids = (list) => list.map((id) => JSON.stringify(id) ?? 'no id').join(', ') || 'none'
    throw new Error(`results do not match the ids sent: missing ${ids(missing)}, unexpected ${ids(unexpected)}`)
  }
}

export async function writeAll(base, items, { cap = 100, attempts = 4, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
  // The server refuses an id repeated in one request, but chunking can put two copies in two
  // requests, where the second would read as already stored. Refuse it before anything is sent.
  const byId = new Map()
  for (const item of items) {
    if (typeof item?.id === 'string' && byId.has(item.id)) throw new Error(`id ${item.id} appears more than once`)
    byId.set(item?.id, item)
  }
  const outcome = { created: [], exists: [], failed: [], unprocessed: [], retried: [], requests: [] }
  let pending = [...items]
  for (let attempt = 1; pending.length > 0; attempt++) {
    const retry = []
    for (const chunk of chunks(pending, cap)) {
      const res = await fetch(`${base}/items/batch`, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ items: chunk }),
      })
      const body = await res.json()
      if (!Array.isArray(body.results)) throw new BatchRefused(res.status, body)
      // One result per input: a short answer would drop items without a trace.
      if (body.results.length !== chunk.length) throw new Error(`sent ${chunk.length} items and got ${body.results.length} results`)
      // The right count is not enough: results for a and a, after sending a and b, lose b.
      checkIds(chunk, body.results)
      outcome.requests.push({ status: res.status, errors: body.errors, items: chunk.length })
      for (const result of body.results) {
        if (result.status === 201) outcome.created.push(result)
        else if (result.status === 200) outcome.exists.push(result)
        // Only a result with an id names an item to resend; one without stays with the failures.
        else if ((result.status === 503 || result.status === 429) && typeof result.id === 'string') {
          retry.push(byId.get(result.id))
          if (!outcome.retried.includes(result.id)) outcome.retried.push(result.id)
        }
        else outcome.failed.push(result)
      }
    }
    pending = retry
    if (pending.length === 0 || attempt >= attempts) break
    await sleep(backoff(attempt))
  }
  outcome.unprocessed = pending
  return outcome
}
```

A 503 goes back on the pile unchanged for the next attempt, after a doubling wait. A 422 goes to
the caller with its problem, because sending it again unchanged would fail again. `byId` is what
the client-supplied id buys: the result names the item, so the client can find and resend it
without position bookkeeping. A whole-batch refusal arrives as a problem document with no
`results`, and the client raises rather than guessing. It also raises when a response holds fewer
results than the chunk had items. It refuses an id that appears twice in its input before sending
anything, because chunking could put the two copies in different requests.

The right count under the wrong ids loses items too: results for `a` and `a`, after sending `a` and
`b`, drop `b` and resend `a` twice. `checkIds` matches the results to the chunk's ids, one each, and
raises an error naming the missing and unexpected ids before the client sorts any result or sends
anything again.
A 503 or 429 without an id stays with the failures, because the client cannot tell which item to
resend.

```bash
node demo.mjs
```

```text output
fix the failed item and resend it alone, by its id
  POST /items/batch -> 200, errors false, 1 item
  ln_b  201  /items/it_6
  store holds 6 rows after 6 writes: it_1 it_2 it_3 it_4 it_5 it_6
```

The failed line is resent alone after its quantity is corrected, and the store ends with six rows
after six writes. Nothing landed twice, which is the property a batch client has to prove rather
than assume.

## Offer atomic as an option, not the default

Some callers need all or nothing: two lines that must exist together, or a store with a
transaction to spend. `atomic: true` validates everything first and writes nothing if any item
fails.

```js title="server.mjs"
  // All or nothing: validate everything first, write nothing if any item fails, and tell the
  // good items why they were not applied, the way Graph answers a dependent with 424.
  function atomic(items) {
    const problems = items.map((item) => validate(item) ?? conflict(item))
    if (problems.some(Boolean)) {
      return items.map((item, i) => problems[i]
        ? { id: typeof item?.id === 'string' ? item.id : null, status: problems[i].status, problem: problems[i] }
        : { id: item.id, status: 424, problem: problem('not-applied', 'Not applied because another item in this batch failed', 424) })
    }
    const fresh = items.filter((item) => !store.has(item.id))
    if (fresh.length > budget) return items.map(notAttempted)
    return items.map((item) => (store.has(item.id) ? exists(item) : write(item)))
  }
```

The good items are told why with a 424.
[RFC 4918](https://www.rfc-editor.org/rfc/rfc4918.html#section-11.4) defines that code for a
request that failed because another one did, and Graph uses it for a dependent of a failed request.
DynamoDB's `TransactWriteItems` answers the same situation with one `CancellationReasons` entry
per item.[^4] Either way the caller learns which item sank the batch.

```bash
node demo.mjs
```

```text output
an atomic batch with the same bad item
  POST /items/batch -> 422, errors true
  ln_g  424  Not applied because another item in this batch failed
  ln_h  422  qty must be a positive integer (item ln_h sent qty 0)
  store still holds 6 rows
```

## Check it worked

The tests count the server's writes: results can be misread, rows in the store cannot.

```bash
node --test batch.test.mjs
```

```text output
1..16
# tests 16
# suites 0
# pass 16
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 340.608041
```

`# pass 16` is the line to look for. The sixth test pins the resend. A cap of four and a budget of
three against six items make three requests, `[207, 4]`, `[200, 2]`, and `[200, 1]`, and the store
counts six writes, so nothing was written twice.

## When it goes wrong

The client reported success and a row is missing. The client checked `res.ok` and stopped, and
`res.ok` is true for a 207.

```bash
node pitfall.mjs
```

```text output
a client that reads res.ok and stops
  status 207, res.ok true, so it reports: all 3 written
  body.errors true, results not 2xx: b 422
  store holds 2 of 3
```

Read `errors` before anything else, and when it is true, walk the results. The status code
cannot carry per-item truth once some items have landed, because any status that admits success
is a 2xx.

The batch endpoint is slow for everyone. The server answers all or nothing by default, so a
batch with one bad row lands no rows and the client has to pull the row out and send the batch
again.

```bash
node pitfall.mjs
```

```text output
1000 items, 10 invalid, batches of 50
  partial success  requests 20  landed after round 1: 990  failed: 10  resent after removing them: 0  landed in the end: 990
  all-or-nothing   requests 30  landed after round 1: 500  failed: 10  resent after removing them: 490  landed in the end: 990
```

Ten bad rows in a thousand cost the atomic shape ten extra requests and held back 490 good rows
for a round trip. The partial shape landed the same 990 rows in one round and reported the ten by
id.

The client retries a 422 three times and gets three more. Only a 503 or a 429 means resend
unchanged; a 4xx means the item is wrong and will stay wrong until something about it changes.
Sort by status before deciding what to resend, as `writeAll` does.

## When not to do this

Do not make atomic the default for independent items. A thousand order lines with one bad
quantity are not a transaction, and treating them as one holds back 999 good rows until a person
fixes a number. Offer atomic to the callers who ask for it, and make them ask per request.

Do not answer 200 with an `errors` flag alone to clients you do not control. The flag is the
right signal for a client that reads it, and the status code is the only signal for a client that
does not. Send both: 207 when some items landed and a 4xx when none did.

Do not retry a 4xx item unchanged. The problem document says what is wrong with it, and a retry
loop that treats every non-2xx the same way turns a validation error into three validation errors
and a wait. Reserve the retry pile for the statuses that mean the server did not try.

## Related how-tos

- [Report partial failure in a batch endpoint](/howto/report-partial-failure-in-a-batch-endpoint)

- [Return validation errors a client can map to a form](/howto/field-level-validation-errors-clients-can-map)

- [Retry fetch calls with exponential backoff in Node.js](/howto/retry-fetch-with-backoff-in-node)

- [Import a CSV file through a bulk ingest job from a script](/howto/import-a-csv-through-a-bulk-job-api)

## Last verified

Verified 2026-10-21 against Node 22.22.0. Every output block is what the command preceding it
printed, against the server in the code directory on 127.0.0.1. The Elasticsearch, Graph,
JSON:API, and DynamoDB behaviors are read from their documentation, linked where each is named,
and were not exercised against those services.

[^1]: The Elasticsearch bulk body is newline-delimited JSON, and the
[reference](https://www.elastic.co/docs/api/doc/elasticsearch/operation/operation-bulk) states
that the final line of data must end with a newline character. It also asks that the actions and
sources not be pretty printed, since a line break inside a document is a new line of the body. A
format in which the last byte decides whether the request parses has kept a generation of client
authors attentive to trailing whitespace.

[^2]: Graph's [batching guide](https://learn.microsoft.com/en-us/graph/json-batching) requires an
`id` on every request, not case-sensitive and unique within the batch, and fails the whole batch
with a 400 otherwise. It then notes that individual responses might appear in a different order
than the requests, and that the id allows the server to process requests in the most efficient
order. The order you sent, in other words, is advice.

[^3]: [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Response/ok) defines `ok` as true
for any status from 200 to 299, so 207 Multi-Status is `ok`. The property answers whether the
envelope arrived, and a client that stops there has read the postmark and not the letter.

[^4]: DynamoDB's
[TransactWriteItems](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html)
lists one cancellation reason per item, in request order, and gives an item with no error the
code `None`. The reference adds that `None` is returned as the literal string "None" rather than
a null or absent value, and that the message field is omitted for such an item. The sentence reads as if
it were written after an SDK had modelled the code as an optional type and found that every item
had one.