How a bulk endpoint handles partial failures without confusing clients
When a client needs to create or update hundreds of entities, hundreds of individual HTTP requests are inefficient, both in network overhead and server load. A well-designed bulk endpoint drastically reduces this overhead, but brings its own design challenge: how do you communicate when some items in a batch succeed and others fail?
Table of Contents
- 1. Why individual requests don't scale for large data volumes
- 2. Response format for mixed successes and failures
- 3. Atomic transaction vs. best-effort processing
- 4. Setting sensible size limits for batches
- 5. Idempotency for repeated bulk requests
- 6. Asynchronous processing for very large batches
- 7. Handling ordering and dependencies between batch items
- 8. Monitoring bulk endpoints separately from single-item endpoints
- 9. Bulk operations at a glance
- 10. Summary
- 11. FAQ
1. Why individual requests don't scale for large data volumes
Every single HTTP request carries a fixed overhead, TCP handshake (unless a connection is reused), TLS handshake, HTTP header parsing, and routing, which occurs a thousand times over for a thousand individual requests, while it only occurs once for a single bulk request. For use cases like an initial data import, synchronization between systems, or a CSV upload with thousands of rows, this difference is the difference between an operation that takes seconds and one that takes minutes or hours.
Beyond the pure network overhead, a bulk endpoint lets the server batch database operations (for example a single bulk INSERT instead of a thousand individual INSERTs), which also drastically reduces actual processing time on the server side, compared to a thousand separate transactions.
2. Response format for mixed successes and failures
The central design question for bulk endpoints is what the response looks like when eight of ten submitted items were processed successfully and two failed for business reasons (such as a validation rule or a duplicate). A single global HTTP status code can't represent this mixed situation, so the response should contain an array with one result per item, each with its own status, its own ID reference back to the original item, and, where applicable, its own error message.
HTTP 207 Multi-Status, originally from WebDAV, is used by some APIs for exactly this case, but isn't universally established. A pragmatic alternative approach is HTTP 200 with a structured body that explicitly distinguishes between successful and failed items, as long as this convention is clearly documented.
<?php
declare(strict_types=1);
final class BulkOrderCreateController
{
public function bulkCreate(array $orderPayloads): array
{
$results = [];
foreach ($orderPayloads as $index => $payload) {
try {
$order = $this->orderService->create($payload);
$results[] = [
'index' => $index,
'status' => 'success',
'id' => $order->getId(),
];
} catch (ValidationException $e) {
$results[] = [
'index' => $index,
'status' => 'error',
'error' => $e->getMessage(),
];
}
}
return [
'total' => count($orderPayloads),
'succeeded' => count(array_filter($results, fn ($r) => $r['status'] === 'success')),
'failed' => count(array_filter($results, fn ($r) => $r['status'] === 'error')),
'results' => $results,
];
}
}
3. Atomic transaction vs. best-effort processing
A bulk endpoint must explicitly decide whether a batch is treated as an atomic unit (either all items succeed or a complete rollback) or operates in best-effort mode (each item is processed independently, individual failures don't block the remaining items). Both approaches have legitimate use cases but very different behavior, which is why the choice should be explicitly communicated and ideally controllable via a query or body parameter.
Atomic semantics fit well with financially sensitive batches, where a partial success would create unacceptable inconsistencies (such as a series of related bookings). Best-effort semantics fit better with independent items, where a single failure shouldn't prevent processing of the remaining, correct items, such as a product catalog import with some faulty records.
4. Setting sensible size limits for batches
A bulk endpoint without a size limit invites a client to try submitting hundreds of thousands of items in a single request, which makes both server-side memory usage and request duration uncontrollable, and in the worst case leads to a timeout without the client knowing whether anything was even processed. An explicit limit (say, a maximum of 500 items per request) with a clear error message when exceeded is therefore mandatory, not optional.
For data volumes larger than the limit allows, the API documentation should explicitly point to paginating the upload (multiple sequential bulk requests) or to an asynchronous bulk import mechanism with job status polling, instead of leaving clients alone with just the error message.
5. Idempotency for repeated bulk requests
On a network error in the middle of a bulk request, the client often doesn't know for certain whether the server already (partially) processed the batch before the response was lost. Without idempotency protection, a naive retry of the entire batch leads to duplicate records for the items that were already processed successfully.
An idempotency key per batch request, analogous to the single-request pattern, prevents this problem: the server recognizes a repeated request with the same key and returns the cached original response again, instead of processing the batch a second time. Additionally, each item in the batch can carry its own client-generated ID to enable server-side duplicate detection at the item level.
6. Asynchronous processing for very large batches
For batches that would exceed the synchronous processing time of a single HTTP request (typically more than a few seconds), the bulk endpoint should instead return HTTP 202 Accepted with a job ID and perform the actual processing asynchronously via a message queue, while the client polls progress through a separate status endpoint. This pattern connects bulk operations with the long-running operations pattern.
The status endpoint should not only return overall progress (such as 340 of 1000 processed) but ideally also already-completed partial results, so a client can react early to individual failures instead of having to wait for the entire processing to finish.
7. Handling ordering and dependencies between batch items
Some batches contain items with dependencies on each other, for example when an item in the same batch references the client-generated ID of another item (an order line referencing a product variant newly created in the same batch). In this case, the server must either guarantee a processing order that respects such dependencies, or clearly document that items are processed independently and in arbitrary order, so clients must resolve dependencies themselves across multiple sequential batches.
Explicit documentation of this guarantee (or its absence) matters, because otherwise clients might implicitly assume an ordering that the server doesn't actually guarantee, which can lead to hard-to-reproduce, race-like failures.
8. Monitoring bulk endpoints separately from single-item endpoints
Bulk endpoints have a fundamentally different load profile than single-item endpoints: a single bulk request can trigger hundreds of database operations internally, which is why standard metrics like requests per second alone are misleading if they lump bulk and single-item endpoints together without separation. More meaningful metrics are items processed per second and average batch size, tracked separately for bulk endpoints.
Equally important is a failure rate tracked per item rather than only per request, since a single failed bulk request with 500 items could still have processed 495 of them successfully, which would show up as a complete failure in a pure request failure rate, even though the actual success rate is 99 percent.
9. Bulk operations at a glance
The table below compares the key design decisions for bulk endpoints.
| Aspect | Option A | Option B |
|---|---|---|
| Failure handling | Atomic: all or nothing | Best-effort: independent items |
| Processing | Synchronous with direct response | Asynchronous with job ID and status polling |
| Response format | HTTP 207 Multi-Status | HTTP 200 with structured result array |
| Size limit | Hard-limited (e.g., 500 items) | Unlimited with asynchronous processing |
Mironsoft
OpenAPI design, Symfony APIs, and API security
APIs that external teams can integrate without back-and-forth questions?
We review existing REST APIs for inconsistent error formats, missing OpenAPI documentation, and security gaps, then build an API that is clearly documented, versioned, and hardened against abuse.
API Review
Checking the OpenAPI spec, error formats, and status codes for consistency.
Symfony Implementation
Using DTOs, Serializer, and Validator for clean, type-safe request/response models.
Security Audit
Hardening rate limiting, auth schemes, and input validation against real attack surfaces.
10. Summary
Bulk Operations: The Essentials at a Glance
Why bulk
Reduces network overhead and enables batched database operations compared to thousands of individual requests.
Partial failure
The response needs one result per item, since a single global status code can't represent mixed successes.
Atomic vs. best-effort
Must be explicitly chosen and communicated, both modes have legitimate, different use cases.
Idempotency
An idempotency key per batch prevents duplicate processing on network failures and retries.