Three tasks that show up in almost every API: process many items at once, run an operation that takes minutes, and return an error in the user's language. Let's cover each in turn.
Batch operations: many items in a single request
A regular REST endpoint accepts one item and returns one result. But what do you do when you need to create a hundred orders at once, or send notifications to a thousand users?
You could send a hundred separate requests — but that's expensive: a hundred TCP connections, a hundred round trips, parsing JSON a hundred times. The right solution is a single request with an array of items that processes them all.
Request and response structure
An endpoint for a batch operation looks like this:
POST /api/v1/orders/batch ← for a collection
POST /api/v1/notifications/batch/send ← for an action over a collection
The request body always contains an items field — an array of items:
export class BatchCreateOrderItemDto {
@ApiProperty()
@IsUUID()
productId: string;
@ApiProperty()
@IsInt()
@Min(1)
quantity: number;
}
export class BatchCreateOrdersRequestDto {
@ApiProperty({ type: [BatchCreateOrderItemDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => BatchCreateOrderItemDto)
items: BatchCreateOrderItemDto[];
}
The response contains a result for each item and an overall summary:
export class BatchItemResultDto {
@ApiProperty()
index: number;
@ApiProperty({ enum: ['SUCCESS', 'ERROR'] })
status: 'SUCCESS' | 'ERROR';
orderId?: string;
error?: { code: string; detail: string };
}
export class BatchSummaryDto {
@ApiProperty() total: number;
@ApiProperty() succeeded: number;
@ApiProperty() failed: number;
}
export class BatchCreateOrdersResponseDto {
@ApiProperty({ type: [BatchItemResultDto] })
results: BatchItemResultDto[];
@ApiProperty()
summary: BatchSummaryDto;
}
Controller
@Post('batch')
@HttpCode(200)
@ApiOperation({ operationId: 'batchCreateOrders', summary: 'Create orders in a batch' })
@ApiResponse({ status: 200, type: BatchCreateOrdersResponseDto })
@ApiResponse({ status: 400, description: 'BATCH_SIZE_EXCEEDED or VALIDATION_ERROR' })
async batchCreate(
@Body() dto: BatchCreateOrdersRequestDto,
): Promise<BatchCreateOrdersResponseDto> {
return this.ordersService.batchCreate(dto.items);
}
@HttpCode(200) is mandatory — without it, NestJS automatically returns 201 for any @Post.
Partial success
An important idea: if one of the items contains an error, that must not cancel the rest. Each item is processed independently, and the result is recorded in the status field:
POST /api/v1/orders/batch
{
"items": [
{ "productId": "c9f3...", "quantity": 2 },
{ "productId": "d1a8...", "quantity": 0 },
{ "productId": "e7b2...", "quantity": 1 }
]
}
HTTP/1.1 200 OK
{
"results": [
{ "index": 0, "status": "SUCCESS", "orderId": "550e..." },
{ "index": 1, "status": "ERROR", "error": { "code": "INVALID_QUANTITY", "detail": "Количество должно быть больше нуля" } },
{ "index": 2, "status": "SUCCESS", "orderId": "6ba7..." }
],
"summary": {
"total": 3,
"succeeded": 2,
"failed": 1
}
}
The response status is 200 OK even with partial errors. A 4xx would be out of place here: the HTTP request succeeded, some of the items just failed business validation. index is the position of the item in the original array, starting from zero.
Atomicity (all or nothing) is not the default behavior. If you need it, it has to be stated explicitly in the endpoint documentation:
@ApiOperation({
summary: 'Atomically reserve stock items',
description: 'All items are processed in a single transaction. A failure of any item rolls back all of them.',
})
Size limit
Accepting an unlimited number of items is dangerous. You need to set a maximum and report exceeding it explicitly:
const MAX_BATCH_SIZE = 100;
async batchCreate(items: BatchCreateOrderItemDto[]): Promise<BatchCreateOrdersResponseDto> {
if (items.length > MAX_BATCH_SIZE) {
throw new BadRequestException({
type: 'urn:problem:order-service:batch-size-exceeded',
status: 400,
title: 'Bad Request',
detail: `Размер группы превышает максимум (${MAX_BATCH_SIZE} элементов)`,
code: 'BATCH_SIZE_EXCEEDED',
});
}
// ...
}
This exception ends up in the HttpExceptionFilter, which will set Content-Type: application/problem+json.
Long-running operations: start and poll for the result
Some operations take not milliseconds but minutes: generating a yearly financial report, a bulk recalculation of data, importing a large customer database. Holding an HTTP connection open the whole time is a bad idea.
The standard solution: start the operation and return a response immediately, and the client periodically asks "how is it going?" until it gets a final status.
Starting the operation
export class GenerateReportRequestDto {
@ApiProperty({ example: '2026-01-01' })
@IsDateString()
dateFrom: string;
@ApiProperty({ example: '2026-12-31' })
@IsDateString()
dateTo: string;
}
export class AsyncTaskResponseDto {
@ApiProperty() taskId: string;
@ApiProperty({ enum: ['PENDING', 'PROCESSING', 'COMPLETED', 'FAILED'] })
status: string;
@ApiProperty() createdAt: string;
@ApiProperty() statusUrl: string;
}
@Post('generate')
@HttpCode(202)
@ApiResponse({ status: 202, type: AsyncTaskResponseDto, headers: { Location: { description: 'Task URL' } } })
async generate(
@Body() dto: GenerateReportRequestDto,
@Res({ passthrough: true }) res: Response,
): Promise<AsyncTaskResponseDto> {
const task = await this.reportsService.startGeneration(dto);
const statusUrl = `/api/v1/tasks/${task.taskId}`;
res.location(statusUrl);
return {
taskId: task.taskId,
status: 'PENDING',
createdAt: task.createdAt,
statusUrl,
};
}
@Res({ passthrough: true }) lets you set the Location header and return the body via return at the same time. Without passthrough: true, NestJS hands full control of the response to Express and return stops working.
The response to the start request:
POST /api/v1/reports/generate
{ "dateFrom": "2026-01-01", "dateTo": "2026-12-31" }
HTTP/1.1 202 Accepted
Location: /api/v1/tasks/550e8400-e29b-41d4-a716-446655440000
{
"taskId": "550e8400-e29b-41d4-a716-446655440000",
"status": "PENDING",
"createdAt": "2026-06-19T10:30:00Z",
"statusUrl": "/api/v1/tasks/550e8400-e29b-41d4-a716-446655440000"
}
202 Accepted means "accepted for processing, but not yet done". The Location header points to where to go for the status.
Polling the status
The client periodically issues a GET on the URL from Location or statusUrl:
@Controller('tasks')
export class TasksController {
@Get(':taskId')
getTask(@Param('taskId', ParseUUIDPipe) taskId: string): Promise<TaskStatusResponseDto> {
return this.tasksService.getTask(taskId);
}
}
Possible responses depending on the state:
In progress:
{
"taskId": "550e8400-...",
"status": "PROCESSING",
"progress": 45,
"createdAt": "2026-06-19T10:30:00Z"
}
Completed:
{
"taskId": "550e8400-...",
"status": "COMPLETED",
"progress": 100,
"createdAt": "2026-06-19T10:30:00Z",
"completedAt": "2026-06-19T10:35:00Z",
"resultUrl": "/api/v1/reports/550e8400-..."
}
Error:
{
"taskId": "550e8400-...",
"status": "FAILED",
"createdAt": "2026-06-19T10:30:00Z",
"completedAt": "2026-06-19T10:32:00Z",
"error": {
"code": "REPORT_GENERATION_FAILED",
"detail": "Данные за указанный период отсутствуют"
}
}
Task statuses:
| Status | Meaning |
|---|---|
PENDING | created, awaiting execution |
PROCESSING | running right now |
COMPLETED | finished; the resultUrl field is mandatory |
FAILED | finished with an error; the error field is mandatory |
How often to poll is up to the client. The server can suggest an interval via the Retry-After header.
Localization: error messages in the user's language
If your API is used by clients from different countries, it's more convenient to receive error messages in their native language. For this, HTTP provides the Accept-Language header — the client specifies the preferred language, and the server responds in it.
Wiring up nestjs-i18n
import { I18nModule, AcceptLanguageResolver } from 'nestjs-i18n';
@Module({
imports: [
I18nModule.forRoot({
fallbackLanguage: 'ru',
loaderOptions: { path: join(__dirname, '/i18n/'), watch: true },
resolvers: [AcceptLanguageResolver],
}),
],
})
export class AppModule {}
AcceptLanguageResolver automatically reads the Accept-Language header from every request. If the header is missing or the language is unknown, the fallbackLanguage is used.
Localizing the error text
@Catch(OrderNotFoundException)
export class OrderNotFoundFilter implements ExceptionFilter {
constructor(private readonly i18n: I18nService) {}
catch(exception: OrderNotFoundException, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const lang = request.headers['accept-language'] ?? 'ru';
const detail = this.i18n.t('errors.ORDER_NOT_FOUND', { lang });
response.status(404).contentType('application/problem+json').json({
type: 'urn:problem:order-service:order-not-found',
status: 404,
title: 'Not Found',
detail,
code: 'ORDER_NOT_FOUND',
});
}
}
The translation files sit alongside:
// i18n/ru/errors.json
{ "ORDER_NOT_FOUND": "Заказ не найден" }
// i18n/en/errors.json
{ "ORDER_NOT_FOUND": "Order not found" }
Localizing validation messages
app.useGlobalPipes(
new ValidationPipe({
transform: true,
exceptionFactory: (errors) => {
const violations = errors.map((e) => ({
field: e.property,
message: Object.values(e.constraints ?? {})[0] ?? '',
}));
return new HttpException(
{
type: 'urn:problem:order-service:validation-error',
status: 400,
title: 'Bad Request',
detail: 'Ошибка валидации входных данных',
code: 'VALIDATION_ERROR',
violations,
},
400,
);
},
}),
);
nestjs-i18n provides an @i18nValidate* decorator for localizing class-validator messages. The message field in violations is localized; the code field is not.
What to localize and what not to
Only what the user sees is localized: the detail and violations[].message fields.
The code, title, and type fields are machine-readable identifiers. Client code does switch (error.code) — it doesn't care about the user's language, and these must be stable. They are not localized.
A common mistake is to translate the error code or the URI:
// Wrong
{ "code": "ЗАКАЗ_НЕ_НАЙДЕН" }
{ "type": "urn:problem:order-service:заказ-не-найден" }
// Correct
{ "code": "ORDER_NOT_FOUND", "detail": "Заказ не найден" }
{ "type": "urn:problem:order-service:order-not-found" }
JSON field names are not localized either — they are part of the API contract.
In short
- Batch operations:
POST /resources/batch, body{ items: [...] }, response{ results, summary }, status200 OK. - Each item is processed independently: a failure of one does not cancel the rest. This is the default behavior; atomicity is declared explicitly.
@HttpCode(200)is mandatory on a batch@Post— otherwise NestJS returns201.- Set a maximum batch size and return
BATCH_SIZE_EXCEEDEDwhen it is exceeded. - Long-running operations:
202 Accepted+ theLocationheader + a body withtaskIdandstatusUrl. - Task statuses:
PENDING→PROCESSING→COMPLETED(withresultUrl) orFAILED(witherror). @Res({ passthrough: true })— to set a header and return the body viareturnat the same time.- Localization via
nestjs-i18n+AcceptLanguageResolver: readsAccept-Language, returns text in the requested language. - Only
detailandviolations[].messageare localized. Codes, URIs, and field names are always in English.
Further reading
- RFC 9457 errors — ProblemDetails via Exception Filters,
exceptionFactoryfor ValidationPipe. - Headers and tracing —
Idempotency-Keyfor batch operations,Locationandtraceparent. - JSON and response format —
content+ pagination metadata,undefinedinstead ofnull. - Rate limiting, files, deprecation —
@nestjs/throttler,StreamableFile,Sunset.