NestJS course Β· Module 8: Caching and Performance

Compression and Response Optimization - speeding up the Empire's messengers

8 min read
In this lesson6

Commander of the legions! The endpoint with the list of legions sends two megabytes of JSON, including fields no client should ever see. Imagine the Empire's messengers carrying huge scrolls of parchment between provinces. A wise praetorian compresses the messages: instead of full chronicles he sends concise reports. In NestJS compression and response optimization are the same art - we shrink responses so they reach the client faster.

Compression Middleware - gzip/deflate compression

Compression is the simplest way to speed up HTTP responses. The compression middleware compresses responses on its own according to the client's Accept-Encoding header:

1// main.ts - adding compression
2import { NestFactory } from '@nestjs/core';
3import { AppModule } from './app.module';
4import compression from 'compression';
5
6async function bootstrap() {
7  const app = await NestFactory.create(AppModule);
8
9  // Enable compression (gzip, deflate and, since version 1.8, brotli)
10  app.use(compression({
11    filter: (req, res) => {
12      // Do not compress responses with the x-no-compression header
13      if (req.headers['x-no-compression']) {
14        return false;
15      }
16      // Default filter - compress text/html, application/json, etc.
17      return compression.filter(req, res);
18    },
19    threshold: 1024,  // Compress responses > 1KB
20    level: 6,         // zlib compression level (0-9, default -1, i.e. 6)
21  }));
22
23  await app.listen(3000);
24}
25
26bootstrap();

The package supports deflate, gzip and, since version 1.8, also brotli: in a test a client accepting gzip, deflate, br got brotli. The middleware also adds the Vary: Accept-Encoding header. The zlib level is a number from 0 to 9, and the default -1 currently means 6. The NestJS documentation advises moving compression to a reverse proxy such as Nginx under heavy traffic.

Response Serialization with class-transformer

class-transformer lets you control which fields of an object leave in the response. It is like a censor of the Empire who decides what information may leave the walls of the Senate:

1// legion.entity.ts
2import { Exclude, Expose, Transform } from 'class-transformer';
3
4export class LegionResponseDto {
5  @Expose()
6  id: number;
7
8  @Expose()
9  name: string;
10
11  @Expose()
12  province: string;
13
14  // Hide internal data - do not send it outside the Empire's walls!
15  @Exclude()
16  internalCode: string;
17
18  @Exclude()
19  secretOrders: string;
20
21  // Transformation - convert to a format citizens can read
22  @Transform(({ value }) => Math.round(value), { toPlainOnly: true })
23  @Expose()
24  soldiers: number;
25
26  @Transform(({ value }) => new Date(value).toLocaleDateString('pl-PL'), { toPlainOnly: true })
27  @Expose()
28  foundedAt: string;
29
30  // Conditional hiding - show only if the user has the rank
31  @Expose({ groups: ['admin', 'senator'] })
32  budget: number;
33}

@Exclude() hides a field, @Expose() shows it, and @Expose({ groups }) shows it only when the request has one of the groups. @Transform() changes a value, and { toPlainOnly: true } limits the change to sending. Without this option the transformation runs twice for objects coming from the database, and in the test the date turned into "Invalid Date".

ClassSerializerInterceptor

Serialization is performed by ClassSerializerInterceptor. We register it globally:

1// app.module.ts - a global serialization interceptor
2import { Module } from '@nestjs/common';
3import { APP_INTERCEPTOR } from '@nestjs/core';
4import { ClassSerializerInterceptor } from '@nestjs/common';
5
6@Module({
7  providers: [
8    {
9      provide: APP_INTERCEPTOR,
10      useClass: ClassSerializerInterceptor,
11    },
12  ],
13})
14export class AppModule {}

Instead of the global registration you can put @UseInterceptors(ClassSerializerInterceptor) on a controller - one or the other, because together they serialize twice. Watch out for a catch: the interceptor works only on class instances. A plain object, e.g. from lean(), passes through untouched - in the test internalCode and secretOrders leaked. The type option helps:

1// legion.controller.ts - usage in a controller
2import { Controller, Get, SerializeOptions } from '@nestjs/common';
3import { LegionResponseDto } from './legion.entity';
4import { LegionService } from './legion.service';
5
6@Controller('legions')
7@SerializeOptions({ type: LegionResponseDto }) // plain objects become DTO instances
8export class LegionController {
9  constructor(private readonly legionService: LegionService) {}
10
11  @Get()
12  findAll(): Promise<LegionResponseDto[]> {
13    // Fields with @Exclude() are removed from the response automatically
14    return this.legionService.findAll();
15  }
16
17  @Get('admin')
18  @SerializeOptions({ type: LegionResponseDto, groups: ['admin'] })
19  findAllAdmin(): Promise<LegionResponseDto[]> {
20    // Fields with @Expose({ groups: ['admin'] }) will be visible
21    return this.legionService.findAll();
22  }
23}

@SerializeOptions({ type }) turns plain objects into DTO instances before serialization. Method options replace class options, which is why we repeat type next to groups.

Pagination - paging responses

Instead of sending all records at once, like the whole army down one road, we split the response into pages. The parameters arrive in the query as text, so a DTO validates them:

1// pagination.dto.ts
2import { Type } from 'class-transformer';
3import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
4
5export class PaginationDto {
6  @IsOptional()
7  @Type(() => Number)
8  @IsInt()
9  @Min(1)
10  page?: number = 1;
11
12  @IsOptional()
13  @Type(() => Number)
14  @IsInt()
15  @Min(1)
16  @Max(100)
17  limit?: number = 20;
18
19  @IsOptional()
20  @IsIn(['id', 'name', 'province'])
21  sortBy?: string = 'id';
22
23  @IsOptional()
24  @IsIn(['ASC', 'DESC'])
25  sortOrder?: 'ASC' | 'DESC' = 'ASC';
26}
27
28export class PaginatedResponse<T> {
29  data: T[];
30  meta: {
31    page: number;
32    limit: number;
33    total: number;
34    totalPages: number;
35    hasNextPage: boolean;
36    hasPreviousPage: boolean;
37  };
38}

@Type(() => Number) turns text into a number when the global ValidationPipe has transform: true. @Max(100) blocks limit=1000000, and @IsIn lets through only known sort fields - sorting by a hidden field, such as a password hash, would reveal its order.

The service fetches the page and the total number of records in parallel:

1// legion.service.ts
2import { Injectable } from '@nestjs/common';
3import { InjectModel } from '@nestjs/mongoose';
4import { Model } from 'mongoose';
5
6@Injectable()
7export class LegionService {
8  constructor(@InjectModel(Legion.name) private legionModel: Model<Legion>) {}
9
10  async findPaginated(pagination: PaginationDto): Promise<PaginatedResponse<any>> {
11    const { page, limit, sortBy, sortOrder } = pagination;
12    const skip = (page - 1) * limit;
13
14    // MongoDB with Mongoose
15    const [data, total] = await Promise.all([
16      this.legionModel
17        .find()
18        .sort({ [sortBy]: sortOrder === 'ASC' ? 1 : -1 })
19        .skip(skip)
20        .limit(limit)
21        .lean()      // lean() returns a POJO instead of a Mongoose Document - faster!
22        .exec(),
23      this.legionModel.countDocuments(),
24    ]);
25
26    const totalPages = Math.ceil(total / limit);
27
28    return {
29      data,
30      meta: {
31        page,
32        limit,
33        total,
34        totalPages,
35        hasNextPage: page < totalPages,
36        hasPreviousPage: page > 1,
37      },
38    };
39  }
40}

lean() returns plain objects, so the type option from the previous section comes in handy during serialization. On large collections countDocuments() can be slow, and skip slows down on distant pages - then the cursor from the optimization lesson helps.

Response Caching Headers

Cache headers let browsers and CDNs keep responses locally. An interceptor sets them on the way back:

1// cache-headers.interceptor.ts
2import {
3  Injectable,
4  NestInterceptor,
5  ExecutionContext,
6  CallHandler,
7} from '@nestjs/common';
8import { createHash } from 'node:crypto';
9import { Observable } from 'rxjs';
10import { tap } from 'rxjs/operators';
11import { Response } from 'express';
12
13@Injectable()
14export class CacheHeadersInterceptor implements NestInterceptor {
15  constructor(
16    private readonly maxAge: number = 300, // 5 minutes by default
17  ) {}
18
19  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
20    const response = context.switchToHttp().getResponse<Response>();
21
22    return next.handle().pipe(
23      tap((body) => {
24        // Cache-Control - how long a messenger may keep a copy
25        response.setHeader(
26          'Cache-Control',
27          `public, max-age=${this.maxAge}, s-maxage=${this.maxAge * 2}`
28        );
29
30        // ETag - the resource version identifier, computed from the content
31        // The browser sends it back in If-None-Match and gets 304 Not Modified
32        response.setHeader('ETag', this.generateETag(body));
33      }),
34    );
35  }
36
37  private generateETag(body: unknown): string {
38    // Same content = same ETag; W/ marks a weak ETag
39    const hash = createHash('sha1').update(JSON.stringify(body)).digest('base64url');
40    return `W/"${hash}"`;
41  }
42}

max-age applies to the browser, s-maxage to shared caches such as a CDN. Use public only for data shared by everyone; user data gets private. The first version computed the ETag from Date.now(), so it changed on every request and a 304 never happened. Express assigns a weak ETag and answers 304 by itself, so your own ETag makes sense when you can compute it more cheaply, e.g. from updatedAt.

The interceptor has a maxAge parameter, so we create it ourselves with new:

1// Usage in a controller
2@Controller('provinces')
3export class ProvinceController {
4  constructor(private readonly provinceService: ProvinceService) {}
5
6  @Get()
7  @UseInterceptors(new CacheHeadersInterceptor(600)) // 10 minutes of cache
8  findAll() {
9    return this.provinceService.findAll();
10  }
11
12  @Get(':id')
13  @UseInterceptors(new CacheHeadersInterceptor(3600)) // 1 hour of cache
14  findOne(@Param('id') id: string) {
15    return this.provinceService.findOne(id);
16  }
17}

With @UseInterceptors(CacheHeadersInterceptor) NestJS would try to inject a number into the constructor. Cache headers are worth learning in this order: Cache-Control is the foundation, ETag adds a version identifier, Vary: Accept-Encoding separates copies in intermediate caches, and If-None-Match is sent by the browser when it asks again.

The @Exclude, @Expose and @Transform decorators - a summary

Three key decorators from class-transformer:

  • @Exclude() - hides a field from the response (like Caesar's secret order),
  • @Expose() - explicitly marks a field to be sent (like a public edict),
  • @Transform() - modifies a value before sending (like the Empire's interpreter).

A complete example combines all three:

1// Complete example
2import { Exclude, Expose, Transform } from 'class-transformer';
3
4export class SoldierResponse {
5  @Expose()
6  id: number;
7
8  @Expose()
9  name: string;
10
11  @Expose()
12  rank: string;
13
14  @Exclude()
15  passwordHash: string;     // Never send the password!
16
17  @Exclude()
18  internalNotes: string;    // Internal notes
19
20  @Transform(({ value }) => value > 1000 ? 'Veteranus' : 'Tiro', { toPlainOnly: true })
21  @Expose()
22  experienceLevel: string;  // Number -> text transformation
23
24  @Transform(({ value }) => value?.toISOString(), { toPlainOnly: true })
25  @Expose()
26  enlistedAt: string;       // Date in ISO format
27}

passwordHash disappears only when the serializer receives a class instance, though - with a plain object the password travels to the client. I recommend a rule: send out only DTOs, never raw entities. In the next lesson the messengers get queues.

Remember: a wise praetor does not send full chronicles when a concise report will do - and never attaches the secret orders to it.

Code for this lesson: src/compression-optimization.ts
1// Compression i Response Optimization
2import { Injectable } from '@nestjs/common';
3import { Exclude, Expose, Transform } from 'class-transformer';
4
5// ===========================================
6// 1. Response DTO with serialization
7// ===========================================
8
9class LegionResponseDto {
10  @Expose()
11  id: number;
12
13  @Expose()
14  name: string;
15
16  @Expose()
17  province: string;
18
19  // Hidden fields - don't send them beyond the walls of the Empire!
20  @Exclude()
21  internalCode: string;
22
23  @Exclude()
24  secretOrders: string;
25
26  @Transform(({ value }) => Math.round(value))
27  @Expose()
28  soldiers: number;
29
30  @Expose({ groups: ['admin'] })
31  budget: number;
32}
33
34// ===========================================
35// 2. Pagination pattern
36// ===========================================
37
38interface PaginatedResponse<T> {
39  data: T[];
40  meta: {
41    page: number;
42    limit: number;
43    total: number;
44    totalPages: number;
45    hasNextPage: boolean;
46  };
47}
48
49@Injectable()
50class PaginationService {
51  paginate<T>(items: T[], page: number, limit: number): PaginatedResponse<T> {
52    const total = items.length;
53    const totalPages = Math.ceil(total / limit);
54    const start = (page - 1) * limit;
55    const data = items.slice(start, start + limit);
56
57    return {
58      data,
59      meta: {
60        page,
61        limit,
62        total,
63        totalPages,
64        hasNextPage: page < totalPages,
65      },
66    };
67  }
68}
69
70// ===========================================
71// 3. Demonstration
72// ===========================================
73
74const service = new PaginationService();
75const legions = Array.from({ length: 50 }, (_, i) => ({
76  id: i + 1,
77  name: 'Legion ' + (i + 1),
78  province: ['Gallia', 'Hispania', 'Britannia'][i % 3],
79  soldiers: Math.floor(Math.random() * 5000) + 1000,
80}));
81
82const page1 = service.paginate(legions, 1, 10);
83const page2 = service.paginate(legions, 2, 10);
84
85console.log('=== Compression & Response Optimization ===');
86console.log('Page 1:', page1.data.length, 'records');
87console.log('Page 2:', page2.data.length, 'records');
88console.log('Total:', page1.meta.total);
89console.log('Pages:', page1.meta.totalPages);
90console.log('');
91console.log('@Exclude() - hides a field from the response');
92console.log('@Expose() - explicitly marks a field');
93console.log('@Transform() - modifies the value');
94console.log('compression() - gzip/deflate middleware');
95console.log('Cache-Control, ETag - HTTP cache headers');
96

Spotted a mistake in this lesson?

Useful articles