JSON Batch Requests: API Patterns, Atomicity, and Performance
Last updated:
JSON batch requests bundle multiple API operations into a single HTTP call, reducing round-trip latency from N×50ms to 1×50ms for N operations. The JSON:API spec defines a batch operations format with an atomic:operations array — each operation has an op field (add, update, remove), an href, and a data field. Facebook's Graph API batch endpoint accepts up to 50 requests as a JSON array with method, relative_url, and body per item. This guide covers JSON array batch request design, atomic vs non-atomic semantics, partial failure handling with HTTP 207 Multi-Status, GraphQL batch queries, and client-side batching with DataLoader.
JSON Batch Request Format Design
The standard JSON batch request sends a top-level array of operation objects to a dedicated endpoint. Each object includes an id for client-side correlation, a method for the HTTP verb, a url for the target resource, and an optional body for request payloads. The id field is critical — it links each response item back to its originating request, especially when items are processed in parallel or return out of order.
// POST /batch — JSON array batch request format
POST /batch HTTP/1.1
Content-Type: application/json
{
"requests": [
{
"id": "req-1",
"method": "GET",
"url": "/users/42"
},
{
"id": "req-2",
"method": "POST",
"url": "/orders",
"body": { "userId": 42, "items": [{ "sku": "ABC", "qty": 2 }] }
},
{
"id": "req-3",
"method": "DELETE",
"url": "/cart/items/99"
}
]
}// Express.js — batch endpoint handler
app.post('/batch', async (req, res) => {
const { requests } = req.body
// Enforce batch size limit
if (!Array.isArray(requests) || requests.length > 100) {
return res.status(400).json({ error: 'Batch must contain 1-100 requests' })
}
// Process all requests (non-atomic: parallel execution)
const results = await Promise.all(
requests.map(async (item) => {
try {
const result = await dispatchRequest(item.method, item.url, item.body)
return { id: item.id, status: 200, body: result }
} catch (err) {
return { id: item.id, status: err.statusCode || 500, body: { error: err.message } }
}
})
)
// Return 207 Multi-Status — individual items have their own status codes
res.status(207).json({ responses: results })
})Include an explicit Content-Type: application/json header on both the request and response. For REST API JSON responses, the batch response should mirror the same error object shape used by your individual endpoints so clients can apply the same error-handling logic to batch items as to standalone calls.
Atomic vs Non-Atomic Batch Semantics
The most important design decision for a batch endpoint is atomicity. An atomic batch wraps all operations in a single database transaction — if any item fails, all changes are rolled back and the server returns a 4xx error identifying the failing item. A non-atomic batch processes each item independently — successes are committed immediately and the server returns HTTP 207 with per-item status codes, allowing partial success.
// Atomic batch — database transaction, all-or-nothing
app.post('/batch/atomic', async (req, res) => {
const { operations } = req.body
const trx = await db.transaction()
try {
const results = []
for (const op of operations) {
// Run each operation within the same transaction
const result = await dispatchInTransaction(trx, op)
results.push({ id: op.id, status: 200, body: result })
}
await trx.commit()
res.status(200).json({ results })
} catch (err) {
await trx.rollback()
// Return the error that caused the rollback — all changes undone
res.status(409).json({
error: 'Batch rolled back due to conflict',
failedOperationId: err.operationId,
detail: err.message,
})
}
})// Non-atomic batch — partial success allowed
// Response shape: 207 Multi-Status with per-item status
{
"responses": [
{ "id": "req-1", "status": 200, "body": { "id": 42, "name": "Ada Lovelace" } },
{ "id": "req-2", "status": 422, "body": { "error": "Insufficient stock for SKU ABC" } },
{ "id": "req-3", "status": 204, "body": null }
]
}Use atomic batches for financial transactions, inventory deductions, and multi-step workflows where partial completion leaves data inconsistent. Use non-atomic batches for independent operations — bulk tag assignments, notification sends, analytics events — where some failures are acceptable and retryable. Document your choice explicitly in the API contract: clients need to know whether to check the top-level status code or each item's status. For robust JSON error handling, always include a machine-readable error field per failed item.
HTTP 207 Multi-Status Response
HTTP 207 Multi-Status (RFC 4918) signals that the response body contains multiple independent status outcomes. For batch APIs, it is the correct top-level status when the batch itself was processed but individual items have mixed results. A 200 response means everything succeeded; a 207 means some items may have failed — clients must inspect each item's status field.
HTTP/1.1 207 Multi-Status
Content-Type: application/json
{
"responses": [
{
"id": "req-1",
"status": 201,
"headers": { "Location": "/orders/ord_789" },
"body": { "id": "ord_789", "total": 49.99, "status": "created" }
},
{
"id": "req-2",
"status": 404,
"body": {
"error": "not_found",
"message": "User 99 does not exist"
}
},
{
"id": "req-3",
"status": 429,
"headers": { "Retry-After": "30" },
"body": {
"error": "rate_limited",
"message": "Too many requests — retry after 30s"
}
}
]
}// Client-side: process 207 response, separate successes from failures
async function sendBatch(requests) {
const res = await fetch('/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requests }),
})
if (res.status !== 207 && res.status !== 200) {
throw new Error(`Batch request failed: ${res.status}`)
}
const { responses } = await res.json()
const succeeded = responses.filter(r => r.status >= 200 && r.status < 300)
const failed = responses.filter(r => r.status >= 400)
const retryable = failed.filter(r => r.status === 429 || r.status >= 500)
return { succeeded, failed, retryable }
}Always include the request id in each response item so clients can correlate results with the original operations. For JSON pagination within batch results, include a cursor or nextPage field in the individual item body rather than at the batch level.
Facebook Graph API Batch Pattern
Facebook's Graph API batch endpoint (POST /v19.0/) is a widely-studied reference implementation. It accepts up to 50 request objects per batch, each with method, relative_url, and an optional body as URL-encoded key-value pairs. The response is a JSON array with one item per request containing code (HTTP status integer), headers (array of {"{'name': ..., 'value': ...'}"} objects), and body (a JSON string that must be parsed separately).
// Facebook Graph API batch request
POST https://graph.facebook.com/v19.0/
Content-Type: application/json
Authorization: Bearer {access_token}
{
"batch": [
{ "method": "GET", "relative_url": "me" },
{ "method": "GET", "relative_url": "me/friends?limit=5" },
{
"method": "POST",
"relative_url": "me/feed",
"body": "message=Hello+World"
}
]
}// Facebook batch response — body is a JSON string, must be parsed
[
{
"code": 200,
"headers": [{ "name": "Content-Type", "value": "application/json" }],
"body": "{"id":"123456","name":"Ada Lovelace"}"
},
{
"code": 200,
"headers": [{ "name": "Content-Type", "value": "application/json" }],
"body": "{"data":[{"id":"789","name":"Charles Babbage"}]}"
},
{
"code": 200,
"headers": [],
"body": "{"id":"post_abc123"}"
}
]Facebook's most powerful batch feature is request dependencies: a later request in the batch can reference the JSON output of an earlier one using JSONPath-like syntax. Name the first request with "name": "get-user", then reference its result in a subsequent request's relative_url as {result=get-user:$.id}. This enables multi-step workflows — create a resource then immediately act on it — within a single HTTP round trip.
// Dependent requests — use result of request "create-post" in next request
{
"batch": [
{
"name": "create-post",
"method": "POST",
"relative_url": "me/feed",
"body": "message=Hello+World"
},
{
"method": "POST",
"relative_url": "{result=create-post:$.id}/likes",
"depends_on": "create-post"
}
]
}JSON:API Atomic Operations
The JSON:API specification's Atomic Operations extension (version 1.1+) provides a standardized format for atomic batch mutations. The request body contains an atomic:operations array at the top level. Each operation object has an op field (add, update, remove), an href or ref identifying the target resource, and a data field with the resource object. All operations succeed or all are rolled back.
// JSON:API Atomic Operations request
POST /operations HTTP/1.1
Content-Type: application/vnd.api+json;ext="https://jsonapi.org/ext/atomic"
Accept: application/vnd.api+json;ext="https://jsonapi.org/ext/atomic"
{
"atomic:operations": [
{
"op": "add",
"href": "/articles",
"data": {
"type": "articles",
"lid": "local-1",
"attributes": { "title": "JSON Batch Requests Explained" }
}
},
{
"op": "update",
"ref": { "type": "articles", "lid": "local-1" },
"data": {
"type": "articles",
"lid": "local-1",
"relationships": {
"author": { "data": { "type": "people", "id": "42" } }
}
}
},
{
"op": "remove",
"ref": { "type": "drafts", "id": "99" }
}
]
}// JSON:API Atomic Operations success response — 200 with results array
// or 204 No Content if no operations return data
HTTP/1.1 200 OK
Content-Type: application/vnd.api+json;ext="https://jsonapi.org/ext/atomic"
{
"atomic:results": [
{
"data": {
"type": "articles",
"id": "art_123",
"lid": "local-1",
"attributes": { "title": "JSON Batch Requests Explained" }
}
},
{ "data": null },
{ "data": null }
]
}
// Failure response — 422 Unprocessable Entity, all operations rolled back
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/vnd.api+json;ext="https://jsonapi.org/ext/atomic"
{
"errors": [{
"id": "validation_error",
"status": "422",
"source": { "pointer": "/atomic:operations/0/data/attributes/title" },
"detail": "title must not be blank"
}]
}The lid (local ID) field is a client-assigned temporary identifier for newly-created resources — it allows later operations in the same batch to reference a resource before the server assigns a permanent id. The server echoes back the lid alongside the assigned server-side id in the atomic:results array.
GraphQL Batching with DataLoader
DataLoader solves the N+1 query problem in GraphQL by coalescing N individual load(id) calls made during a single event loop tick into one batch function invocation. The batch function receives an array of all ids accumulated during that tick and returns a Promise resolving to an array of results in the same order. A per-request cache ensures each id is fetched at most once per GraphQL request, even if it appears in multiple resolvers.
// Without DataLoader — N+1 problem: 1 query for posts + N queries for authors
// For 10 posts, this executes 11 SQL queries
const resolvers = {
Post: {
author: async (post) => {
return db.users.findById(post.authorId) // 1 query per post!
}
}
}// With DataLoader — 1 SQL query for all authors regardless of post count
import DataLoader from 'dataloader'
// Create per-request DataLoader (never share across requests)
function createLoaders() {
return {
user: new DataLoader(async (ids) => {
// Single SQL query: SELECT * FROM users WHERE id IN (...)
const users = await db.users.findByIds(ids)
// CRITICAL: return results in the same order as ids
// DataLoader requires result[i] corresponds to ids[i]
return ids.map(id => users.find(u => u.id === id) ?? new Error(`User ${id} not found`))
}),
}
}
// In GraphQL context (per-request)
const server = new ApolloServer({
typeDefs,
resolvers,
context: () => ({ loaders: createLoaders() }),
})
const resolvers = {
Post: {
// All 10 post.author calls happen in same tick -> 1 batch function call -> 1 SQL query
author: (post, _, { loaders }) => loaders.user.load(post.authorId)
}
}// DataLoader options for fine-tuning batching behavior
const userLoader = new DataLoader(batchFn, {
// maxBatchSize: cap batch at N items (default: Infinity)
maxBatchSize: 100,
// batchScheduleFn: control the tick-coalescing window
// Default uses process.nextTick; override for custom timing
batchScheduleFn: (callback) => setTimeout(callback, 10),
// cache: set false to disable per-request caching (rarely needed)
cache: true,
})For parsing JSON in Node.js within DataLoader batch functions, ensure your SQL client returns rows as plain objects — most ORMs do this by default but some return class instances that require explicit serialization before passing to GraphQL resolvers.
Client-Side Batch Queuing
Client-side batch queuing accumulates individual API calls within a short time window (typically 10–50ms) and flushes them as a single batch request. This is the client-side equivalent of DataLoader: instead of modifying the server, you coalesce outgoing requests in the browser or API gateway before they leave the client. The queuing window trades minimal latency (the flush delay) for significant throughput gains when many calls happen in rapid succession.
// Client-side batch queue — accumulate calls, flush every 20ms
class BatchQueue {
constructor(batchUrl, windowMs = 20, maxSize = 50) {
this.batchUrl = batchUrl
this.windowMs = windowMs
this.maxSize = maxSize
this.queue = []
this.timer = null
}
// Public API: returns a Promise for the individual request result
request(method, url, body = null) {
return new Promise((resolve, reject) => {
const id = `req-${Date.now()}-${Math.random().toString(36).slice(2)}`
this.queue.push({ id, method, url, body, resolve, reject })
// Flush immediately if we hit the size limit
if (this.queue.length >= this.maxSize) {
this.flush()
return
}
// Start the flush timer if not already running
if (!this.timer) {
this.timer = setTimeout(() => this.flush(), this.windowMs)
}
})
}
async flush() {
if (this.timer) { clearTimeout(this.timer); this.timer = null }
if (this.queue.length === 0) return
// Drain the queue
const batch = this.queue.splice(0)
const requests = batch.map(({ id, method, url, body }) => ({ id, method, url, body }))
try {
const res = await fetch(this.batchUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requests }),
})
const { responses } = await res.json()
// Resolve/reject each promise based on its item status
for (const item of responses) {
const pending = batch.find(b => b.id === item.id)
if (!pending) continue
if (item.status >= 200 && item.status < 300) {
pending.resolve(item.body)
} else {
pending.reject(Object.assign(new Error(item.body?.error || 'Request failed'), { status: item.status }))
}
}
} catch (err) {
// Network failure — reject all pending promises
batch.forEach(b => b.reject(err))
}
}
}
// Usage — callers get individual Promises; batching is transparent
const api = new BatchQueue('/batch')
const [user, order] = await Promise.all([
api.request('GET', '/users/42'),
api.request('GET', '/orders/ord_789'),
])Implement retry logic only for items that returned 429 (rate limited) or 5xx (server error) — never auto-retry 4xx client errors since they will fail again. For 429 responses, respect the Retry-After header value as the delay before re-queuing the failed items.
Key Terms
- Batch request
- A single HTTP call that contains multiple independent API operations encoded as a JSON array. The server processes all operations and returns a corresponding array of results. Reduces round-trip latency from N×RTT to 1×RTT for N operations.
- Atomic operation
- A batch execution mode where all operations are wrapped in a single database transaction. If any operation fails, all changes are rolled back — the batch produces either complete success or zero changes. The JSON:API Atomic Operations extension formalizes this pattern with
atomic:operationsarrays. - Non-atomic batch
- A batch execution mode where each operation is processed independently. Successful operations are committed immediately; failures do not affect other items. The server returns HTTP 207 Multi-Status with per-item status codes, enabling partial success.
- 207 Multi-Status
- An HTTP response status code (RFC 4918) indicating that the response body contains multiple independent status outcomes. Used by batch endpoints to signal that some items succeeded while others failed — clients must inspect each item's
statusfield rather than relying solely on the top-level HTTP status code. - DataLoader
- A JavaScript library that coalesces N individual
load(id)calls made within a single event loop tick into a single batch function invocation. Eliminates the N+1 query problem in GraphQL resolvers by executing one SQL query for all ids accumulated during a tick, then distributing results back to individual callers via their Promises. - Batch window
- The time interval (typically 10–50ms) during which a client-side batch queue accumulates individual API calls before flushing them as a single batch request. Shorter windows reduce latency but capture fewer calls per batch; longer windows improve batching efficiency at the cost of added delay for time-sensitive operations.
- Local ID (lid)
- A client-assigned temporary identifier for a resource being created within a JSON:API atomic operations batch. Allows subsequent operations in the same batch to reference the new resource before the server has assigned a permanent server-side
id. The server returns thelidalongside the assignedidin theatomic:resultsarray.
FAQ
What is a JSON batch API request?
A JSON batch API request bundles multiple individual API operations into a single HTTP call. Instead of making N separate requests — each with its own TCP handshake, TLS negotiation, and round-trip — the client sends one request containing a JSON array of operations and receives one response containing a JSON array of results. This reduces latency from N×RTT to 1×RTT and cuts HTTP overhead significantly for high-volume operations.
How do I design a JSON batch endpoint?
Accept a JSON array of operation objects at a dedicated URL (e.g., POST /batch). Each operation should include an id for correlation, a method (GET, POST, PUT, DELETE), a url for the target resource, and an optional body. Return a JSON array of result objects with matching id, status, and body per item. Set an explicit batch size limit (e.g., 100) and return HTTP 400 if exceeded. Decide upfront whether operations are atomic or non-atomic.
What does HTTP 207 Multi-Status mean for batch APIs?
HTTP 207 Multi-Status signals that the batch was processed but individual items have different outcomes. The response body contains a JSON array where each item has its own status code — some may be 200/201 (success), others 400/404/500 (failure). A 207 response means the batch request itself succeeded; it does not mean all items succeeded. Clients must inspect each item's status field to determine individual outcomes.
What is the difference between atomic and non-atomic batch operations?
An atomic batch wraps all operations in a database transaction — any failure rolls back all changes and returns a 409/422 error. Non-atomic batches process each item independently — successes commit immediately and the server returns HTTP 207 with per-item statuses, allowing partial success. Use atomic batches for financial transactions and inventory updates; use non-atomic for independent operations like bulk notifications or tag assignments.
How does Facebook Graph API batch work?
Send a POST to https://graph.facebook.com/v19.0/ with a batch parameter containing a JSON array of up to 50 request objects. Each object needs method, relative_url, and optionally body (URL-encoded). The response is a JSON array where each item has code (HTTP status), headers, and body (a JSON string that must be parsed). Use the name field and {result=name:$.field} syntax to pass results between dependent requests in the same batch.
What is DataLoader and how does it batch requests?
DataLoader is a JavaScript library that coalesces N load(id) calls made during a single event loop tick into one batch function call. Your batch function receives an array of all ids and executes a single SQL query (WHERE id IN (...)). Results must be returned in the same order as the input ids. DataLoader also caches results per request, so the same id is fetched at most once per GraphQL request — eliminating the N+1 query problem.
How many items can I include in a JSON batch request?
Limits vary by platform: Facebook Graph API allows 50 requests, AWS DynamoDB BatchWriteItem allows 25 items, DynamoDB BatchGetItem allows 100 items. For custom endpoints, common limits are 100–500 items based on memory and timeout budgets. Set an explicit maximum, return HTTP 400 if exceeded, and also enforce a payload size limit (e.g., 10MB) independent of item count.
How do I handle partial failures in a batch API?
For non-atomic batches: return HTTP 207 with a JSON array where each item has an id, a status code, and a bodywith either the success payload or an error object. Clients should check each item's status and retry only items that returned 429 or 5xx — never auto-retry 4xx errors. For atomic batches: return the first error as a top-level HTTP error (409 or 422) with a field identifying which operation caused the rollback.
Further reading and primary sources
- JSON:API Atomic Operations Extension — Official specification for the JSON:API atomic:operations array format, op field values, lid local IDs, and success/error response shapes
- Facebook Graph API Batch Requests — Reference documentation for the Facebook Graph API batch endpoint: format, size limits, dependent requests, and binary attachments
- DataLoader — GitHub (graphql/dataloader) — Source and documentation for the DataLoader library: batch function contract, caching semantics, and scheduling options
- RFC 4918 — HTTP Extensions for WebDAV (207 Multi-Status) — Standard definition of HTTP 207 Multi-Status, including the multistatus response body format
- AWS DynamoDB BatchWriteItem — AWS documentation for DynamoDB batch writes: 25-item limit, unprocessed items handling, and retry patterns