A client posts three ledger lines in one request. Two are written and the third fails validation, and your endpoint has one status code to describe all of it. Answer 200 and the client never learns about the third line. Answer 422 and it sends all three again, and the first two are written twice.
What you get
You will end up with a batch handler that reports one outcome per item, keyed by the id the client sent, under a status code that is wrong for nobody. This is for you if your API accepts an array of writes and a caller has to know which ones landed.
Short answer
Give every item a client-supplied id and answer with one outcome per item, each failure an RFC 9457 problem document. Use 207 when some items failed, 200 when all succeeded, and a 4xx when none did, so a client that reads only the status still learns the truth. Reserve all-or-nothing rejection for a store that can roll the whole batch back.
You will need
Node 22 or later, and a batch endpoint that accepts an array of items. The per-item failures below are RFC 9457 problem documents, and the 207 status comes from RFC 4918 section 13, which is the WebDAV specification.1 Decide the status for each single-item error first; the batch status is derived from those.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| 207 Multi-Status | Independent writes where most succeed and the client can retry the rest | A body every client has to parse before it knows anything, and a 2xx that still needs reading | A half-written batch is a corrupt state in your store |
| All-or-nothing rejection | A transactional store, and items that only make sense together | One bad item rejects every good one, so a large batch rarely lands on the first try | The items are independent and the batch is big |
Elasticsearch _bulk response | Clients generated from the schema, which always read the errors flag | A 200 on a batch where nothing was written, which a status-only client reads as success | Clients you do not control, which check the status and stop |
Microsoft Graph $batch | Requests inside the batch depend on each other and a failure has to cascade | A 200 envelope around per-request statuses, and dependents that fail with 424 | The items are independent and a cascade refuses work that could have landed |
Per-item results make the client do more work, and they are what keeps a large batch practical: a thousand lines with one bad row lands nine hundred and ninety-nine of them. All-or-nothing is simpler to reason about and needs a store that can roll back, which is why DynamoDB documents it as a transaction rather than a batch. The errors-flag shape is the per-item shape with the batch status removed, and the removal is the pitfall this page is about.
Key every item by the id the client sent
Position is the obvious key and the wrong one. The server may skip a duplicate, reject an item before the array is walked, or reorder for the store. After any of those, the client’s index into its own array no longer points at the item that failed. An id the client chose survives all of that, and it doubles as the item’s idempotency key when the client sends it again.
function outcomes(items, store, { write }) {
const seen = new Set()
return items.map((item) => {
if (item === null || typeof item !== 'object') {
return { id: null, status: 422, problem: { type: `${PROBLEMS}/item-not-object`, title: 'Item is not an object', status: 422 } }
}
let problem = validate(item)
if (!problem && seen.has(item.id)) {
problem = { type: `${PROBLEMS}/duplicate-id`, title: 'Item id repeated in this batch', status: 422 }
}
seen.add(item.id)
if (problem) return { id: item.id ?? null, status: problem.status, problem }
if (store.has(item.id)) return { id: item.id, status: 200 }
if (write) store.set(item.id, { amount: item.amount })
return { id: item.id, status: 201 }
})
}
Each outcome carries the id, a status, and, on failure, a problem document with a type URI a
client can switch on. The per-item status is the one the same write would have earned as a single
request, which is what lets a client reuse its single-request error handling item by item. The
outer response is application/json: the application/problem+json media type describes one
document, and here there are several inside a list. An item that is not an object gets a failed
outcome of its own instead of throwing the batch away. An id already in the store answers 200
without a second write.
Pick the batch status from the item outcomes
The batch status is derived, not chosen. An empty batch is refused first, because it has written nothing, and after that the rule has three branches.
export function multiStatus(items, store) {
if (items.length === 0) return { status: 422, body: EMPTY }
const results = outcomes(items, store, { write: true })
const failed = results.filter((r) => r.problem).length
const status = failed === 0 ? 200 : failed === results.length ? 422 : 207
return { status, body: { results } }
}
The third branch is the one that matters. 207 sits in the 2xx class, and
Response.ok is true for any status from 200 to 299. A client that checks ok and moves on treats a batch where every item
failed as a success. Elasticsearch’s _bulk answers 200 with errors: true and leaves the reading to you. Amazon’s SQS reference tells its callers, in so many words, to check for
batch errors even when the call returned a 200.2 A 422 for the all-failed case costs nothing
and reaches the clients that never read the body.
Check it worked
Three ledger lines, the second with a negative amount, through all three shapes. Then a batch where every item fails, then a retry of only the failed item.
node demo.mjs
item 2 of 3 fails
multi-status status 207 written 2 of 3 failed ln_b
errors-flag status 200 written 2 of 3 failed ln_b
atomic status 422 written 0 of 3 failed ln_b
every item fails
multi-status status 422 res.ok false written 0
errors-flag status 200 res.ok true written 0
atomic status 422 res.ok false written 0
the client retries only what failed, under multi-status
first call 207, retried ln_b, second call 200
ledger holds 3 lines: ln_a,ln_c,ln_b
the failing item, as the client sees it
{
"id": "ln_b",
"status": 422,
"problem": {
"type": "https://api.example.com/problems/amount-not-positive",
"title": "Amount must be greater than zero",
"status": 422,
"detail": "amount was -5"
}
}
The middle block is the whole argument in three lines. Nothing was written under any shape, and
only the errors-flag shape says res.ok true. The retry block is what per-item results buy: the
client resent ln_b alone, the second call was a plain 200, and the ledger holds three lines with
no duplicate of ln_a or ln_c.
The test pins the id rule by running the same batch reversed and asserting the failure is still
ln_b.
node --test batch.test.mjs
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 121.023088
When it goes wrong
Every item failed and the client reported success. The endpoint answered 200 with a flag in the body, and the client checked the status and stopped.3 Derive the batch status from the outcomes and answer a 4xx when nothing was written.
The client retried the wrong item. The response reported failures by position, the server had dropped a duplicate before validating, and index 1 in the response was index 2 in the request. Report the client’s own id and never a position.
A retry after a 207 wrote two lines twice. The client resent the whole batch instead of the failed
items, and the endpoint had no idempotency rule per item. Treat the client’s item id as the key
for that item, so a resend of a line already written answers 200 and leaves the stored line alone, the way an
Idempotency-Key
does for a single request.
When not to do this
Do not report per-item outcomes from a transactional store. If two of three lines cannot exist
without the third, a 207 describes a corrupt ledger accurately, which is no comfort. Validate
everything, write nothing on any failure, and answer a 422 that names the first failing item and
lists the rest, as the atomic shape here does.
Do not go all-or-nothing on a large batch of independent items. One malformed row in a thousand sends the other nine hundred and ninety-nine back to the client. A client that generates rows from user data will rarely get a thousand clean ones on the first try.
Do not answer 207 for a batch where every item succeeded. The status exists to say that the body needs reading, and a client that learns to ignore it on the happy path ignores it on the other one.
Do not let a batch grow without a cap. A per-item response is as large as the batch, and a client that sends ten thousand items gets ten thousand outcomes back in one body.
Related how-tos
Last verified
Verified 2026-09-24 against Node 22.22.2. Both output blocks are what the preceding command printed. The three shapes run as functions over an in-memory store, called directly rather than through an HTTP server.
Footnotes
-
207 is a WebDAV status. RFC 4918 defines the Multi-Status body as XML with a
multistatusroot element, and RFC 9110 does not define 207 at all; the IANA status code registry lists it against RFC 4918. JSON APIs borrowed the number and left the body behind, which is a tidy example of how a status code escapes its specification. 424 Failed Dependency, which Microsoft Graph uses for a request whose parent failed, comes from the same document. ↩︎ Back to text -
The sentence is in the SendMessageBatch reference. Because a batch can mix successful and unsuccessful actions, it says, you should check for batch errors even when the call returns an HTTP status code of 200. A vendor documenting the failure mode of its own response shape is rarer than it should be, and the sentence has outlived several generations of client libraries that did not read it. ↩︎ Back to text
-
Microsoft’s batching guide says a 200 on the batch response does not indicate that the individual requests inside it succeeded. It then describes a batch in which requests 2 and 3 failed with 403 and request 4 with 405, all under that 200. The guide is one of the few that puts the mixed result in its own worked example rather than in a footnote. ↩︎ Back to text