← Back to the section

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:

StatusMeaning
PENDINGcreated, awaiting execution
PROCESSINGrunning right now
COMPLETEDfinished; the resultUrl field is mandatory
FAILEDfinished 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 }, status 200 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 returns 201.
  • Set a maximum batch size and return BATCH_SIZE_EXCEEDED when it is exceeded.
  • Long-running operations: 202 Accepted + the Location header + a body with taskId and statusUrl.
  • Task statuses: PENDINGPROCESSINGCOMPLETED (with resultUrl) or FAILED (with error).
  • @Res({ passthrough: true }) — to set a header and return the body via return at the same time.
  • Localization via nestjs-i18n + AcceptLanguageResolver: reads Accept-Language, returns text in the requested language.
  • Only detail and violations[].message are localized. Codes, URIs, and field names are always in English.

Further reading

  • RFC 9457 errors — ProblemDetails via Exception Filters, exceptionFactory for ValidationPipe.
  • Headers and tracing — Idempotency-Key for batch operations, Location and traceparent.
  • JSON and response format — content + pagination metadata, undefined instead of null.
  • Rate limiting, files, deprecation — @nestjs/throttler, StreamableFile, Sunset.