← Back to the section

HTTP headers are the metadata of a request and response: the content type, the address of a created resource, the authorization token, the trace identifier. Let's see how to work with them in NestJS: what to read, what to set, and how to avoid the typical naming pitfalls.

Standard headers

Every HTTP request and response has headers whose meaning is defined by the standard. There's nothing to invent — you just need to use them correctly.

Incoming headers are read via the @Headers() decorator:

@Get(':id')
async findOne(
  @Param('id') id: string,
  @Headers('if-none-match') ifNoneMatch: string,
  @Res({ passthrough: true }) res: Response,
) {
  const order = await this.ordersService.findOne(id);
  const etag = `"${order.version}"`;

  if (ifNoneMatch === etag) {
    res.status(304).end();
    return;
  }

  res.setHeader('ETag', etag);
  return order;
}

Here ETag is the version of the resource: the client sends the current value in If-None-Match, and the server compares. If the resource hasn't changed, it returns 304 Not Modified without a body — saving traffic.

The Location header is set when a resource is created (201 Created) — it tells the client at what address to find the created object:

@Post()
async create(
  @Body() dto: CreateOrderDto,
  @Res({ passthrough: true }) res: Response,
) {
  const order = await this.ordersService.create(dto);
  res.location(`/api/v1/orders/${order.orderId}`);
  return order;
}

A common mistake is to write just /${order.orderId} without the full path. The client gets an incomplete URL and can't make a GET request for the created resource.

Custom headers: why without X-

If you need to pass data that isn't in the standard (a client identifier, an application version, a request identifier), you use your own headers. It used to be common to name them with the X- prefix (X-Request-Id, X-Tenant-Id). In 2012, RFC 6648 deprecated this approach: X- means nothing and only clutters the names.

The modern approach is a domain prefix: take the name of your project or product and use it as a namespace.

@Post()
async create(
  @Headers('shop-request-id') requestId: string,
  @Headers('shop-client-version') clientVersion: string,
  @Body() dto: CreateOrderDto,
) {
  this.logger.log({ requestId, clientVersion }, 'create order');
  return this.ordersService.create(dto);
}

HTTP headers are case-insensitive, so shop-request-id and Shop-Request-Id are the same thing. The convention is to write them in lowercase with hyphens.

Idempotent POST via Idempotency-Key

Idempotency is when a repeated request produces the same result as the first one. For GET this works on its own. For POST it doesn't: if the client didn't receive the response (a timeout, a dropped connection) and repeated the request, the server may create two objects instead of one.

Idempotency-Key solves this problem: the client generates a unique key (UUID v4) before the operation and sends it in a header. The server remembers the key and the result of the first execution. On a repeated request with the same key, it returns the saved result without performing the operation again.

export class IdempotencyGuard implements CanActivate {
  constructor(private readonly idempotencyService: IdempotencyService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const req = context.switchToHttp().getRequest<Request>();
    const key = req.headers['idempotency-key'] as string;

    if (!key) {
      throw new BadRequestException('Idempotency-Key header is required');
    }

    const existing = await this.idempotencyService.find(key);
    if (existing) {
      const res = context.switchToHttp().getResponse<Response>();
      res.status(existing.status).json(existing.body);
      return false;
    }

    return true;
  }
}

@Post()
@UseGuards(IdempotencyGuard)
async create(
  @Headers('idempotency-key') key: string,
  @Body() dto: CreateOrderDto,
): Promise<OrderResponse> {
  return this.ordersService.create(dto, key);
}

Important contract details:

  • One key — one business operation. The client generates it once and keeps it until it receives a successful response.
  • A repeated POST with the same key → the server returns the first result.
  • If a different body is sent with the same key → 409 Conflict.
  • Idempotency-Key only makes sense for POST and PATCH. GET requests are idempotent by nature.

traceparent: request tracing

When one request from a client passes through several services, it's hard to understand where something went wrong. That's what distributed tracing is for: each request gets a unique identifier (trace-id) that is passed from service to service in the traceparent header. As a result, all the steps of one request can be seen in a single trace.

The standard is called W3C Trace Context. The traceparent header looks like this:

00-1f2a8b6c7d3e4f5a9b0c1d2e3f4a5b6c-7a8b9c0d1e2f3a4b-01
│  │                                │                │
│  trace-id (32 hex characters)     parent-id (16)   flags
version

In NestJS, tracing is wired up through the OpenTelemetry SDK — once at application startup, before the NestJS application is created:

// main.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';

const sdk = new NodeSDK({
  instrumentations: [new HttpInstrumentation()],
});
sdk.start();

After that, traceparent is extracted from the incoming request automatically. In code, traceId is available through the OpenTelemetry API:

// telemetry/trace-context.ts
import { context, trace } from '@opentelemetry/api';

export function getTraceId(): string {
  const span = trace.getActiveSpan();
  return span?.spanContext().traceId ?? '';
}

Most often traceId is needed in the error body — so the client can pass it to support:

export function sendProblem(res: Response, status: number, title: string, detail: string): void {
  res.status(status).type('application/problem+json').json({
    title,
    status,
    detail,
    traceId: getTraceId(),
  });
}

The logic is simple: if the client sent a traceparent, the service picks up its trace-id and creates a new parent-id for its own step. If the header wasn't there, HttpInstrumentation generates a new traceparent on entry.

In short

  • Read incoming headers via @Headers('header-name').
  • Location on 201 Created is set via res.location(...) with the full path.
  • ETag + If-None-Match allow returning 304 without a body when the resource hasn't changed.
  • Name your own headers with a domain prefix (shop-request-id), not with X- — the X- prefix has been deprecated since 2012.
  • Idempotency-Key protects POST from double creation on repeated requests — one key per business operation, the server remembers the result.
  • Tracing via traceparent is wired up once in main.ts through HttpInstrumentation — after that everything is automatic.

Further reading

  • REST errors in NestJS — how to include traceId in an RFC 9457 error body.
  • JSON and response format in NestJS — Location on 201 and formatting the body.
  • Rate limiting, files, deprecation — the Retry-After and Sunset headers.