JSON Bulk Operations: Batch API Design, Partial Failures & JSONL Streaming
Last updated:
A bulk operation sends multiple create, update, or delete actions in a single HTTP request instead of one request per item. Rather than making 1,000 individual POST requests to create 1,000 records — each with its own TCP handshake, TLS negotiation, and round-trip latency — a bulk API accepts all 1,000 in one payload and returns a single response describing what happened to each. On a typical network, this reduces total latency from 10–60 seconds down to under one second. Use bulk operations when you need to import data sets, sync records between systems, process queued events, or apply batch updates to large collections. Avoid bulk operations when items are truly independent real-time events that benefit from individual error handling, when the payload is too large to buffer in memory, or when your database does not support efficient multi-row operations. For streaming imports of millions of records, JSON Lines (NDJSON) is the right format: one JSON object per line, streamed without buffering the entire body, with results streamed back as each record is processed.
Bulk Operations vs Individual Requests
Each HTTP request carries fixed overhead: DNS resolution, TCP connection establishment, TLS handshake, HTTP headers, and server-side request parsing. On a low-latency internal network, this overhead is 1–5 ms per request; over the public internet it is commonly 50–200 ms. Sending 1,000 individual requests therefore wastes 1–200 seconds on overhead alone before any business logic runs. A single bulk request amortizes all of that overhead across all items, reducing total time to the cost of one round trip plus the server's processing time.
Beyond latency, bulk operations enable more efficient database access. A single INSERT INTO users VALUES (...), (...), (...) with 500 rows is significantly faster than 500 individual INSERTs — the database parses the query once, acquires locks once, and writes to disk in fewer fsync calls. Connection pool exhaustion is also avoided: 1,000 concurrent individual requests can saturate a pool of 20 connections, whereas a single bulk request uses one connection and processes serially.
Bulk operations are wrong when individual items have meaningfully different latency characteristics (e.g., some trigger expensive downstream calls), when partial failure is unacceptable and rollback is expensive, or when the payload is too large to buffer. For payloads above ~10 MB or item counts above ~1,000, use NDJSON streaming rather than a single buffered JSON array. For truly massive imports, use an async job pattern: accept the batch, return a job ID, and process in the background.
// ── Individual requests: 1000 round trips ─────────────────────────────
const users = getUsersToCreate(); // 1000 items
// Sequential: ~50-200ms × 1000 = 50-200 seconds
for (const user of users) {
await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user),
});
}
// Parallel with concurrency limit: better, but still 50-200 round trips
// even at concurrency=20, still 50 batches × round-trip overhead
// ── Bulk request: 1 round trip ────────────────────────────────────────
const response = await fetch('/api/users/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operations: users }),
});
const { results } = await response.json();
// Check per-item outcomes (HTTP 207 body)
const failed = results.filter(r => r.status >= 400);
const succeeded = results.filter(r => r.status < 400);
console.log(`${succeeded.length} created, ${failed.length} failed`);
// Retry only the failed items — idempotency key prevents duplicates
if (failed.length > 0) {
const retryPayload = failed.map(r => users[r.index]);
await fetch('/api/users/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operations: retryPayload }),
});
}JSON Bulk Request Formats
There is no single standard for JSON bulk request formats, but four patterns dominate in production APIs. The choice depends on payload size, whether you need mixed operation types (create + update + delete in one batch), and whether streaming is required.
The array of operations format is the most common for REST APIs. The request body is a JSON object with an operations array where each element describes one action. This is easy to validate with JSON Schema and easy to correlate with a per-item response array. The JSON Lines (NDJSON) format uses one JSON object per line, separated by newline characters, with no wrapping array or object. It is ideal for streaming because the server can start processing before the full body arrives. The Elasticsearch _bulk format uses alternating NDJSON pairs: an action/metadata line followed by a source document line. The JSON Patch array (RFC 6902) describes changes as a sequence of patch operations (add, remove, replace, move, copy, test) — useful for bulk patching a single resource's fields.
// ── Format 1: Array of operations (REST standard) ─────────────────────
// POST /api/products/bulk
// Content-Type: application/json
{
"operations": [
{ "method": "create", "data": { "name": "Widget", "price": 9.99 } },
{ "method": "update", "id": "prod_123", "data": { "price": 12.99 } },
{ "method": "delete", "id": "prod_456" }
]
}
// ── Format 2: JSON Lines / NDJSON (streaming imports) ─────────────────
// POST /api/products/bulk-import
// Content-Type: application/x-ndjson
{"name":"Widget A","price":9.99,"sku":"WA-001"}
{"name":"Widget B","price":14.99,"sku":"WA-002"}
{"name":"Gadget Pro","price":99.99,"sku":"GP-001"}
// ── Format 3: Elasticsearch _bulk API format ──────────────────────────
// POST /_bulk
// Content-Type: application/x-ndjson
// Action line: tells ES what to do
{"index":{"_index":"products","_id":"1"}}
// Document line: the source document
{"name":"Widget","price":9.99}
// Next action + document pair:
{"delete":{"_index":"products","_id":"2"}}
// (delete has no document line — action only)
{"update":{"_index":"products","_id":"3"}}
{"doc":{"price":12.99}}
// ── Format 4: JSON Patch array (RFC 6902) ─────────────────────────────
// PATCH /api/products/prod_123
// Content-Type: application/json-patch+json
[
{ "op": "replace", "path": "/price", "value": 14.99 },
{ "op": "add", "path": "/tags/-", "value": "sale" },
{ "op": "remove", "path": "/discount" },
{ "op": "test", "path": "/version", "value": 5 }
]
// ── Client: send NDJSON from a JavaScript array ────────────────────────
const records = [
{ name: 'Widget A', price: 9.99 },
{ name: 'Widget B', price: 14.99 },
];
const ndjson = records.map(r => JSON.stringify(r)).join('\n');
await fetch('/api/products/bulk-import', {
method: 'POST',
headers: { 'Content-Type': 'application/x-ndjson' },
body: ndjson,
});Handling Partial Success in Bulk Responses
The defining challenge of bulk API design is communicating mixed outcomes: some items succeeded, some failed, and clients need to know exactly which is which without making additional requests. The HTTP protocol provides HTTP 207 Multi-Status precisely for this scenario. The 207 status code tells the client "the batch was processed, but you need to read the body to see what happened to each item." A response of HTTP 200 for a batch where some items failed is incorrect — clients that only check the top-level status code will silently miss errors.
The response body should be an array of result objects that mirrors the input array by index. Each result includes: the input index (0-based position in the request array), an id for the affected record (especially important for creates where the server generated the ID), a status field with the HTTP status code for that individual item (201 for created, 200 for updated, 404 for not found, 422 for validation error, 409 for conflict), and for failures, an error object with a machine-readable code and a human-readable message.
// ── HTTP 207 Multi-Status response body ───────────────────────────────
// POST /api/products/bulk → HTTP 207
{
"summary": {
"total": 5,
"succeeded": 3,
"failed": 2
},
"results": [
{
"index": 0,
"status": 201,
"id": "prod_abc",
"data": { "name": "Widget", "price": 9.99, "createdAt": "2026-02-01T10:00:00Z" }
},
{
"index": 1,
"status": 422,
"error": {
"code": "VALIDATION_ERROR",
"message": "price must be a positive number",
"fields": { "price": "received -5, must be > 0" }
}
},
{
"index": 2,
"status": 201,
"id": "prod_def",
"data": { "name": "Gadget", "price": 49.99, "createdAt": "2026-02-01T10:00:00Z" }
},
{
"index": 3,
"status": 409,
"error": {
"code": "DUPLICATE_SKU",
"message": "SKU 'WA-001' already exists",
"conflictId": "prod_xyz"
}
},
{
"index": 4,
"status": 201,
"id": "prod_ghi",
"data": { "name": "Tool", "price": 19.99, "createdAt": "2026-02-01T10:00:00Z" }
}
]
}
// ── Server implementation (Express) ───────────────────────────────────
app.post('/api/products/bulk', async (req, res) => {
const { operations } = req.body;
if (!Array.isArray(operations) || operations.length === 0) {
return res.status(400).json({ error: 'operations must be a non-empty array' });
}
if (operations.length > 500) {
return res.status(413).json({ error: 'batch size exceeds maximum of 500 items' });
}
const results = await Promise.all(
operations.map(async (op, index) => {
try {
const product = await db.products.create(op.data);
return { index, status: 201, id: product.id, data: product };
} catch (err) {
if (err.code === '23505') { // unique constraint violation
return { index, status: 409, error: { code: 'DUPLICATE_SKU', message: err.detail } };
}
if (err.name === 'ValidationError') {
return { index, status: 422, error: { code: 'VALIDATION_ERROR', message: err.message } };
}
return { index, status: 500, error: { code: 'INTERNAL_ERROR', message: 'unexpected error' } };
}
})
);
const succeeded = results.filter(r => r.status < 400).length;
const failed = results.filter(r => r.status >= 400).length;
// HTTP 207 for mixed outcomes; 200 only if all succeeded
const httpStatus = failed > 0 ? 207 : 200;
res.status(httpStatus).json({
summary: { total: operations.length, succeeded, failed },
results,
});
});Transaction Semantics for Bulk Operations
Every bulk API must declare its transaction semantics: will the entire batch succeed or fail together (atomic), or will each item be committed independently (non-atomic / best-effort)? This choice has significant implications for both your server implementation and your client's error-handling logic.
Atomic semantics wrap all items in a single database transaction. If any item fails, the transaction rolls back and no changes are committed. The HTTP response is a clean success (200) or a clean failure (400/422) — there is no partial state for the client to reconcile. Atomic semantics are essential for financial operations (debit one account, credit another), inventory adjustments, and any scenario where partial application would leave data in an inconsistent state. The cost: large transactions hold locks longer, are expensive to roll back, and limit your ability to scale batch size.
Non-atomic (best-effort) semantics process each item in its own transaction or without a transaction. Items that succeed are committed immediately; items that fail do not affect already-committed items. The HTTP response is 207 Multi-Status with per-item outcomes. Clients must handle partial success by checking each result and retrying failures — using idempotency keys to prevent duplicates on retry. Best-effort semantics scale better and are appropriate for data import pipelines, background sync jobs, and scenarios where partial progress is acceptable and resumable.
// ── Atomic bulk: all-or-nothing transaction (PostgreSQL + node-postgres)
import { Pool } from 'pg';
const pool = new Pool();
async function atomicBulkCreate(items: ProductInput[]): Promise<Product[]> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const created: Product[] = [];
for (const item of items) {
// Validate before inserting — throw to trigger rollback
if (!item.name || item.price < 0) {
throw new ValidationError(`invalid item: ${JSON.stringify(item)}`);
}
const { rows } = await client.query(
'INSERT INTO products (name, price, sku) VALUES ($1, $2, $3) RETURNING *',
[item.name, item.price, item.sku]
);
created.push(rows[0]);
}
await client.query('COMMIT');
return created;
} catch (err) {
await client.query('ROLLBACK'); // undo everything on any failure
throw err;
} finally {
client.release();
}
}
// Express handler for atomic endpoint
app.post('/api/products/bulk-atomic', async (req, res) => {
try {
const products = await atomicBulkCreate(req.body.operations);
res.status(200).json({ results: products.map((p, i) => ({ index: i, status: 201, data: p })) });
} catch (err) {
res.status(422).json({ error: err.message, committed: false });
}
});
// ── Non-atomic (best-effort) with idempotency keys ─────────────────────
async function bestEffortBulkCreate(
items: Array<ProductInput & { idempotencyKey: string }>
) {
return Promise.all(items.map(async (item, index) => {
// Check idempotency cache first (Redis TTL: 24 hours)
const cached = await redis.get(`idem:${item.idempotencyKey}`);
if (cached) {
return { index, status: 200, data: JSON.parse(cached), cached: true };
}
try {
const { rows } = await pool.query(
`INSERT INTO products (name, price, sku, idempotency_key)
VALUES ($1, $2, $3, $4)
ON CONFLICT (idempotency_key) DO UPDATE SET name = EXCLUDED.name
RETURNING *`,
[item.name, item.price, item.sku, item.idempotencyKey]
);
const product = rows[0];
await redis.setex(`idem:${item.idempotencyKey}`, 86400, JSON.stringify(product));
return { index, status: 201, id: product.id, data: product };
} catch (err) {
return { index, status: 422, error: { code: 'ERROR', message: err.message } };
}
}));
}Streaming Bulk Operations with JSON Lines
For bulk imports exceeding a few megabytes — tens of thousands of records or more — buffering the entire request body into memory before processing is impractical. NDJSON (Newline-Delimited JSON) solves this with a streaming format: the client sends one JSON object per line, the server reads and processes each line as it arrives, and results are streamed back line-by-line before the upload completes. Peak memory usage stays roughly proportional to one record's size rather than the full dataset.
The key to correct streaming is backpressure handling. If the client sends data faster than the server can write to the database, the in-flight records accumulate in memory and you regain the problem you were trying to avoid. Node.js streams implement backpressure through the drain event and the return value of write(): when write() returns false, stop reading from the input until the drain event fires. The readline module and the Transform stream API handle this correctly if you pause and resume the source stream.
// ── Server: streaming NDJSON import with backpressure (Express + Node.js)
import readline from 'readline';
import { pool } from './db';
// IMPORTANT: skip body-parser for this route — read req stream directly
app.post('/api/products/stream-import', async (req, res) => {
// Stream results back as NDJSON
res.writeHead(200, {
'Content-Type': 'application/x-ndjson',
'Transfer-Encoding': 'chunked',
'X-Accel-Buffering': 'no', // disable Nginx buffering for real-time results
});
let processed = 0;
let errors = 0;
const rl = readline.createInterface({ input: req, crlfDelay: Infinity });
// Process batch of N lines at a time for efficient DB inserts
const BATCH_SIZE = 100;
let batch: string[] = [];
const flushBatch = async () => {
if (batch.length === 0) return;
const records = batch.map(line => JSON.parse(line));
batch = [];
// Bulk insert the batch using unnest for efficiency
const names = records.map(r => r.name);
const prices = records.map(r => r.price);
const skus = records.map(r => r.sku);
try {
const { rows } = await pool.query(
`INSERT INTO products (name, price, sku)
SELECT * FROM unnest($1::text[], $2::numeric[], $3::text[])
ON CONFLICT (sku) DO NOTHING
RETURNING id, sku`,
[names, prices, skus]
);
processed += rows.length;
// Stream result line back to client
res.write(JSON.stringify({ type: 'batch_result', inserted: rows.length }) + '\n');
} catch (err) {
errors++;
res.write(JSON.stringify({ type: 'batch_error', message: err.message }) + '\n');
}
};
rl.on('line', async (line) => {
if (!line.trim()) return; // skip blank lines
batch.push(line);
if (batch.length >= BATCH_SIZE) {
rl.pause(); // backpressure: pause input while flushing
await flushBatch();
rl.resume(); // resume after flush completes
}
});
rl.on('close', async () => {
await flushBatch(); // flush remaining items
res.write(JSON.stringify({ type: 'done', processed, errors }) + '\n');
res.end();
});
rl.on('error', (err) => {
res.write(JSON.stringify({ type: 'stream_error', message: err.message }) + '\n');
res.end();
});
});
// ── Client: stream NDJSON from a file ─────────────────────────────────
import fs from 'fs';
import http from 'http';
const fileStream = fs.createReadStream('products.ndjson');
const reqOptions = {
hostname: 'api.example.com',
path: '/api/products/stream-import',
method: 'POST',
headers: { 'Content-Type': 'application/x-ndjson', 'Transfer-Encoding': 'chunked' },
};
const req = http.request(reqOptions, (res) => {
const rl = readline.createInterface({ input: res });
rl.on('line', (line) => console.log(JSON.parse(line)));
});
fileStream.pipe(req);Implementing Bulk APIs in Express and Next.js
Express and Next.js both require configuration changes to handle bulk API payloads. The default body size limit in Express's bodyParser.json() middleware is 100 kb — a limit designed for individual API requests that is easily exceeded by bulk payloads. A batch of 500 product objects with descriptions can easily reach 2–5 MB. For NDJSON streaming endpoints, bypass body-parser entirely and read from the raw request stream.
// ── Express: increase body size limit ────────────────────────────────
import express from 'express';
const app = express();
// Default is '100kb' — bulk endpoints need 10mb or more
app.use(express.json({ limit: '10mb' }));
// For NDJSON streaming routes, skip body-parser and read req stream:
app.post('/api/bulk-stream', (req, res) => {
// req is a Readable stream — do NOT use req.body here
// body-parser has not consumed it, so it is still streamable
});
// ── Next.js App Router: increase body limit ───────────────────────────
// app/api/products/bulk/route.ts
import { NextRequest, NextResponse } from 'next/server';
// Disable the default body size limit for this route
export const config = {
api: { bodyParser: { sizeLimit: '10mb' } },
};
export async function POST(req: NextRequest) {
const body = await req.json(); // parses up to 10mb
const { operations } = body;
if (!Array.isArray(operations)) {
return NextResponse.json({ error: 'operations must be an array' }, { status: 400 });
}
if (operations.length > 500) {
return NextResponse.json({ error: 'max batch size is 500' }, { status: 413 });
}
// Validate each item
const validated = operations.map((op, index) => {
const errors: string[] = [];
if (!op.name || typeof op.name !== 'string') errors.push('name is required');
if (typeof op.price !== 'number' || op.price < 0) errors.push('price must be a non-negative number');
return { index, op, errors };
});
const invalidItems = validated.filter(v => v.errors.length > 0);
if (invalidItems.length > 0) {
// Atomic mode: reject entire batch on any validation failure
return NextResponse.json({
error: 'validation failed',
details: invalidItems.map(v => ({ index: v.index, errors: v.errors })),
}, { status: 422 });
}
// Bulk insert with PostgreSQL unnest (single round-trip for all rows)
const names = operations.map(op => op.name);
const prices = operations.map(op => op.price);
const skus = operations.map(op => op.sku ?? null);
const { rows } = await pool.query(
`INSERT INTO products (name, price, sku)
SELECT * FROM unnest($1::text[], $2::numeric[], $3::text[])
ON CONFLICT (sku) DO UPDATE
SET name = EXCLUDED.name, price = EXCLUDED.price
RETURNING id, name, price, sku, created_at`,
[names, prices, skus]
);
return NextResponse.json(
{
summary: { total: operations.length, succeeded: rows.length, failed: 0 },
results: rows.map((row, i) => ({ index: i, status: 201, id: row.id, data: row })),
},
{ status: 200 }
);
}
// ── PostgreSQL unnest: the fastest multi-row insert pattern ───────────
// Instead of: INSERT INTO t VALUES ($1,$2), ($3,$4), ($5,$6) ...
// Use unnest: it avoids building a dynamic query string and handles
// thousands of rows efficiently:
//
// INSERT INTO products (name, price)
// SELECT * FROM unnest($1::text[], $2::numeric[])
// RETURNING id
//
// This sends arrays as PostgreSQL parameters — no query string grows
// with item count, no SQL injection surface.Rate Limiting and Pagination for Bulk Operations
Bulk APIs amplify the impact of both well-behaved clients and abusive ones. A single request can insert 500 records, run 500 database queries, and allocate 10 MB of server memory. Rate limiting for bulk APIs must account for item count, not just request count — an API rate limit of "100 requests per minute" that allows batches of 500 items each effectively permits 50,000 operations per minute.
For large result sets that do not fit in a single bulk response, use cursor-based pagination. Return a cursor in the response that the client sends with the next request to retrieve the next page. Cursor-based pagination is stable under concurrent writes (unlike offset-based pagination where inserted rows can cause items to be skipped or duplicated). For bulk exports where the full dataset is too large to return synchronously, use an async job pattern: accept the request, start a background export job, return a job ID, and let the client poll a status endpoint until the export file is ready for download.
// ── Rate limiting by item count (Express + express-rate-limit) ────────
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
// Standard request-level limit: 100 requests per minute per IP
const requestLimiter = rateLimit({ windowMs: 60_000, max: 100 });
// Item-level limit: track total items per IP, not just request count
const itemCostLimiter = async (req, res, next) => {
const ip = req.ip;
const batchSize = Array.isArray(req.body?.operations) ? req.body.operations.length : 1;
const ITEM_LIMIT = 5_000; // max 5,000 items per minute per IP
const windowMs = 60_000;
const key = `bulk_items:${ip}`;
const current = await redis.incrby(key, batchSize);
if (current === batchSize) {
await redis.pexpire(key, windowMs); // set TTL only on first increment
}
if (current > ITEM_LIMIT) {
const ttl = await redis.pttl(key);
res.setHeader('Retry-After', Math.ceil(ttl / 1000));
return res.status(429).json({
error: 'item rate limit exceeded',
limit: ITEM_LIMIT,
current,
retryAfterSeconds: Math.ceil(ttl / 1000),
});
}
next();
};
app.post('/api/products/bulk', requestLimiter, itemCostLimiter, bulkHandler);
// ── Cursor-based pagination for bulk reads ────────────────────────────
// GET /api/products?limit=500&cursor=eyJpZCI6MTAwfQ
app.get('/api/products', async (req, res) => {
const limit = Math.min(parseInt(req.query.limit as string) || 100, 500);
const cursor = req.query.cursor as string | undefined;
let afterId = 0;
if (cursor) {
// Cursor is base64(JSON): { id: lastSeenId }
afterId = JSON.parse(Buffer.from(cursor, 'base64url').toString()).id;
}
const { rows } = await pool.query(
'SELECT * FROM products WHERE id > $1 ORDER BY id ASC LIMIT $2',
[afterId, limit + 1] // fetch one extra to detect hasNextPage
);
const hasNextPage = rows.length > limit;
const items = hasNextPage ? rows.slice(0, limit) : rows;
const nextCursor = hasNextPage
? Buffer.from(JSON.stringify({ id: items[items.length - 1].id })).toString('base64url')
: null;
res.json({ items, pagination: { limit, cursor: nextCursor, hasNextPage } });
});
// ── Async bulk job for large exports ──────────────────────────────────
// POST /api/exports → 202 Accepted { jobId: "exp_abc123" }
// GET /api/exports/exp_abc123 → { status: "pending"|"done"|"failed", url? }
app.post('/api/exports', async (req, res) => {
const jobId = `exp_${crypto.randomUUID()}`;
await queue.add('bulk-export', { jobId, filters: req.body.filters });
res.status(202).json({ jobId, statusUrl: `/api/exports/${jobId}` });
});Key Terms
- Bulk Operation
- An API pattern that processes multiple records — creates, updates, or deletes — in a single HTTP request and response cycle. Bulk operations reduce per-request overhead (TCP connections, TLS handshakes, HTTP headers, server-side parsing) and enable more efficient database access (multi-row INSERTs, batch statements). The server returns a response that describes the outcome of each item, either as part of a 207 Multi-Status body or as a stream of result lines. Bulk operations are distinguished from batch requests by their intent: bulk specifically refers to applying the same type of operation to many items, while batch more broadly refers to grouping any set of API calls.
- Batch Request
- A single HTTP request that encapsulates multiple independent API operations, potentially of different types (GET, POST, PATCH, DELETE) targeting different endpoints. Batch requests reduce HTTP round trips by combining operations that would otherwise require separate requests. Some APIs (Google APIs, Microsoft Graph) implement a generic batch endpoint that accepts an array of subrequest objects, each with a method, URL, headers, and body. The server executes each subrequest and returns an array of subresponses. This differs from a bulk operation, which processes many items through a single operation type (e.g., bulk create) rather than mixing operation types.
- JSON Lines (NDJSON)
- A text format where each line is a valid, self-contained JSON value (typically an object), separated by newline characters (
\n). Also called Newline-Delimited JSON (NDJSON) or JSON Lines (JSONL). Unlike a JSON array, NDJSON does not require a wrapping bracket, so the server and client can process each line as it arrives without waiting for the full body. This makes NDJSON ideal for streaming large datasets, log files, and bulk imports. The MIME type isapplication/x-ndjson. Each line must be a complete, parseable JSON document — partial JSON spanning multiple lines is not valid NDJSON. Blank lines are typically ignored. NDJSON is used by Elasticsearch's _bulk API, log aggregators, and data pipeline tools like Apache Kafka and Flink. - 207 Multi-Status
- An HTTP response status code (defined in RFC 4918, WebDAV) that signals a batch operation was processed but individual items have mixed outcomes. The response body contains per-item status information. In the context of JSON bulk APIs, 207 is returned when some items in a batch succeeded and others failed — the client must read the response body's
resultsarray and check each item'sstatusfield to determine what happened. Returning 200 for a batch with partial failures is incorrect and causes clients to silently miss errors. Return 200 only if every item succeeded. Return 400 or 422 if the batch request itself is malformed. Return 207 for mixed outcomes. - Atomic Transaction
- A database transaction property where all operations within the transaction either succeed entirely and are committed, or fail entirely and are rolled back — there is no partial state. In the context of bulk APIs, atomic semantics mean that if any item in the batch fails validation or a database constraint, the entire batch is aborted and no changes are persisted. The client receives a single error response. Atomic bulk operations are simpler for clients (no partial state to reconcile) but limit scalability: large transactions hold locks longer and are expensive to roll back. The alternative is non-atomic (best-effort) semantics where each item is processed independently.
- Partial Success
- A bulk operation outcome where some items in the batch succeed and others fail. Partial success is the natural outcome of non-atomic (best-effort) bulk APIs where each item is processed independently. The HTTP response is 207 Multi-Status with a per-item results array. Clients handling partial success must iterate the results array, identify failed items by their non-2xx status codes, and decide whether to retry them (using idempotency keys to avoid duplicates), discard them, or surface them as errors. Partial success is appropriate for data import pipelines where partial progress is acceptable; it is inappropriate for financial transactions where partial application would leave accounts in an inconsistent state.
- Chunked Transfer Encoding
- An HTTP/1.1 transfer mechanism (defined in RFC 7230) that allows a server to send a response body in a series of chunks without knowing the total content length in advance. Each chunk is preceded by its size in hexadecimal. Chunked encoding enables streaming: the server writes each result line to the response as soon as it is computed, rather than buffering the entire body and sending it at the end. Clients receive and process chunks as they arrive. Chunked transfer encoding is essential for long-running bulk operations where the server wants to report progress in real time and avoid HTTP timeouts that occur when a silent connection is open for minutes. In Express, chunked encoding is enabled by calling
res.writeHead()withTransfer-Encoding: chunkedand writing chunks withres.write()beforeres.end(). - Backpressure
- A flow control mechanism in streaming systems that signals an upstream producer to slow down when the downstream consumer cannot keep up. In Node.js streams, backpressure is communicated by the return value of the
write()method: if it returnsfalse, the writable stream's internal buffer is full and the readable stream should pause. When the writable drains, it emits adrainevent signaling the readable to resume. Without backpressure handling in a streaming NDJSON bulk import, a fast client upload can overwhelm a slow database write, causing in-flight records to accumulate in memory until the server runs out of resources. The Node.jspipeline()function and the.pipe()method handle backpressure automatically when connecting streams; manualreadline+pause()/resume()is required when processing line-by-line.
FAQ
What is the best JSON format for a bulk create or update API?
For moderate payloads under a few megabytes, use a top-level JSON object with an operations array: each element includes a method field ("create", "update", "delete"), an optional id for updates and deletes, and a data object. This format is easy to validate with JSON Schema, easy to correlate with a per-item response array using the array index, and works naturally with standard JSON parsers. For larger payloads or streaming imports, use JSON Lines (NDJSON): one JSON object per line, newline-delimited, no wrapping array or object, with Content-Type application/x-ndjson. NDJSON allows the server to begin processing before the full body arrives, which is critical for payloads with thousands or millions of items. The Elasticsearch _bulk API uses a two-line NDJSON pattern: an action/metadata line followed by a document line, alternating — this is worth adopting if you need to support mixed operations (index, update, delete) in one stream. For REST APIs patching a single resource, use a JSON Patch array (RFC 6902) with operation objects containing op, path, and value fields. Document your format in an OpenAPI spec with examples of the 207 Multi-Status response.
How do I handle partial failures in a JSON bulk operation?
Return a per-item result array in the response body that mirrors the input array index-for-index. Each result object should include: index (0-based position in the request array), id (the record's ID, especially for creates where the server generates it), status (the HTTP status code for that individual item — 201 for created, 200 for updated, 422 for validation error, 409 for conflict, 404 for not found), and for failures, an error object with a machine-readable code and a human-readable message. Return HTTP 207 Multi-Status as the top-level HTTP status when any item fails. Add a summary object with total, succeeded, and failed counts to help clients quickly determine if any action is needed. Clients must iterate the results array and check each item's status field. For retries, use idempotency keys: clients should include a deterministic key per item (e.g., SHA-256 of the payload) so the server can return the cached result for already-processed items without creating duplicates. Never return HTTP 200 for a batch where any item failed — clients that only check the top-level status code will silently miss errors.
What HTTP status code should a bulk operation return when some items fail?
Use HTTP 207 Multi-Status when a bulk operation processes items individually and some succeed while others fail. HTTP 207 is defined in RFC 4918 and is now widely adopted for batch APIs beyond WebDAV. The 207 response signals that the server processed the batch but each item has its own outcome — the client must inspect the response body to find per-item status codes. The response body should be an array of result objects, each with a status property holding the HTTP status code for that item. Use HTTP 200 only when every single item succeeded. Use HTTP 400 when the batch request itself is malformed — for example, if the body is not valid JSON or required top-level fields are missing before you can even attempt processing. Use HTTP 413 Payload Too Large when the batch exceeds your maximum allowed size. Use HTTP 429 Too Many Requests when a rate limit is exceeded — include a Retry-After header. Use HTTP 422 Unprocessable Entity for atomic batches where validation fails before any item is committed. Use HTTP 500 only for unexpected server errors that prevented processing the batch entirely. Never return HTTP 200 for partial failures — this is the most common bulk API design mistake.
How do I stream large JSON bulk imports using NDJSON/JSON Lines?
Send the request body as a stream of newline-separated JSON objects with Content-Type application/x-ndjson. On the server, bypass body-parser middleware and read the request as a raw Node.js stream. Use the built-in readline module to split the stream into lines: const rl = readline.createInterface({ input: req }); then handle each line in the line event with JSON.parse(line). Collect lines into batches of 100–500 and flush each batch to the database using a multi-row insert (PostgreSQL unnest or INSERT ... VALUES (...), (...)). Implement backpressure: call rl.pause() before the async flush and rl.resume() after it completes — this prevents fast uploads from outpacing database writes and accumulating records in memory. Stream results back to the client using chunked transfer encoding: res.writeHead(200, { 'Transfer-Encoding': 'chunked', 'Content-Type': 'application/x-ndjson' }) and write one result JSON line per flushed batch with res.write(JSON.stringify(result) + '\n'). In Next.js App Router, use the ReadableStream API to stream responses. Set Express's body-parser limit to a high value (e.g., 500 MB) or skip it entirely for streaming routes.
What is the maximum batch size I should allow in a JSON bulk API?
For synchronous bulk endpoints that buffer the full request and return a full response, set a maximum of 100 to 500 items. This range balances throughput (fewer HTTP requests than individual calls) with memory pressure (the request body, validation results, and database response all fit comfortably in memory), database transaction size (a 500-row insert runs in well under a second on most databases), and response latency (under 30 seconds even for complex per-item processing). At 100–500 items with typical record sizes, the request body stays under 10 MB, which is manageable with a body-parser limit increase. Return HTTP 413 Payload Too Large when the item count exceeds your maximum — include the limit in the error response body. For larger imports (thousands to millions of items), switch to an NDJSON streaming endpoint (no practical item count limit, but enforce a maximum request body size of 500 MB) or an async job endpoint that accepts the batch, returns HTTP 202 Accepted with a job ID, and processes records in a background worker. Rate limit by item count, not just request count: an IP sending 100 requests/minute of 500 items each submits 50,000 items/minute.
How do I make JSON bulk operations idempotent and safe to retry?
Require clients to provide a client-generated idempotency key for each item in the batch — a UUID or a deterministic hash derived from the item's content (e.g., SHA-256 of the serialized payload). The server stores a mapping from idempotency key to the result in Redis or a database table with a TTL of 24 hours. On each item, check the cache before processing: if the key exists, return the cached result with a cached: true flag without re-executing the operation. This allows the client to resend the entire batch after a network failure and receive correct results for already-processed items without creating duplicates. For create operations, store the idempotency key as a unique index column in the database: use INSERT ... ON CONFLICT (idempotency_key) DO UPDATE SET ... RETURNING * to make the database itself idempotency-safe without a separate cache lookup. For update operations, combine idempotency keys with optimistic locking: include a version field in the request and reject with 409 Conflict if the current version does not match. Clients should generate idempotency keys deterministically from content so that a crashed client regenerates the same keys on restart and can safely resume a partial batch.
What is the difference between atomic and non-atomic bulk operations?
An atomic bulk operation wraps all items in a single database transaction: either every item succeeds and is committed, or the entire batch fails and is rolled back — there is no partial state. The HTTP response is either HTTP 200 (all succeeded) or HTTP 422 (one or more items failed, nothing committed). Atomic semantics are simpler for clients because they never need to reconcile partial state, but they limit scalability: large transactions hold locks longer, are expensive to roll back, and require validating all items before committing any. Use atomic semantics for financial operations (debit and credit must both succeed or both fail), inventory adjustments, and any scenario where partial application would leave data inconsistent. A non-atomic (best-effort) bulk operation processes each item independently in its own mini-transaction: items that succeed are committed immediately; items that fail are recorded as errors without affecting other items. The HTTP response is HTTP 207 Multi-Status with per-item outcomes. Clients must handle partial success by checking each result's status, retrying failed items with idempotency keys, and handling the possibility that some items were committed and others were not. Use non-atomic semantics for data import pipelines, bulk notification sending, and background sync jobs where partial progress is acceptable and resumable.
Further reading and primary sources
- RFC 4918: HTTP Extensions for WebDAV (207 Multi-Status) — The RFC defining HTTP 207 Multi-Status, used in bulk API design for per-item outcome reporting
- Elasticsearch Bulk API Documentation — Elasticsearch _bulk API reference: NDJSON format, action types, and partial failure handling
- JSON Lines (NDJSON) Specification — The NDJSON format specification: newline-delimited JSON for streaming and bulk data
- RFC 6902: JavaScript Object Notation (JSON) Patch — JSON Patch format for describing changes to a JSON document — used in bulk update scenarios
- Node.js Streams Documentation: Backpressure — Official Node.js guide to understanding and implementing backpressure in streaming pipelines