Understanding NestJS Performance
NestJS performance refers to how efficiently your NestJS application handles incoming requests, processes data, and returns responses under various load conditions. It encompasses throughput (requests per second), latency (response time), memory consumption, and CPU utilization. By default, NestJS runs on top of Express.js, which is a mature and capable HTTP framework, but there are numerous optimization techniques that can dramatically improve your application's performance characteristics.
Performance optimization in NestJS is not a single action but a continuous process of measuring, identifying bottlenecks, applying targeted improvements, and validating results. The framework's modular architecture and dependency injection system provide excellent foundations, but real-world applications often need tuning in areas like HTTP server selection, response compression, caching strategies, database interaction, and concurrency handling.
Why NestJS Performance Matters
In production environments, performance directly impacts user experience, infrastructure costs, and system reliability. A slow API leads to frustrated users, higher bounce rates, and potential revenue loss. Under heavy load, poorly optimized NestJS applications consume excessive CPU and memory, forcing you to provision more servers than necessary. Moreover, in microservices architectures where NestJS excels, inter-service communication latency multiplies across the call chain, making individual service performance critical. Optimized NestJS applications can handle thousands of concurrent requests with minimal resources, scale horizontally with ease, and maintain stable performance under sustained traffic spikes.
Switching from Express to Fastify
The single most impactful performance change you can make is replacing the default Express adapter with Fastify. Fastify is purpose-built for speed, offering significantly higher throughput and lower latency compared to Express, while maintaining a similar middleware and plugin ecosystem.
Installing and Configuring Fastify
npm install @nestjs/platform-fastify fastify @fastify/compress @fastify/cookie
Update your main.ts to use the Fastify adapter:
import { NestFactory } from '@nestjs/core';
import {
FastifyAdapter,
NestFastifyApplication,
} from '@nestjs/platform-fastify';
import { AppModule } from './app.module';
import compression from '@fastify/compress';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({
logger: true,
maxParamLength: 100,
bodyLimit: 10485760, // 10MB
}),
);
await app.register(compression, {
brotliOptions: { params: { quality: 4 } },
encodings: ['br', 'gzip', 'deflate'],
});
app.enableCors();
await app.listen(3000, '0.0.0.0');
console.log(`Application is running on: ${await app.getUrl()}`);
}
bootstrap();
With Fastify, you gain access to its optimized request parsing pipeline, schema-based serialization, and lower per-request overhead. Benchmarks consistently show Fastify handling 2-3x more requests per second than Express with comparable CPU usage.
Implementing Response Compression
Compression reduces payload sizes, decreasing bandwidth usage and speeding up transmission times, especially for mobile clients or slow networks. NestJS supports compression both at the platform level and through middleware.
Compression with Express
import * as compression from 'compression';
// In your main.ts bootstrap function
app.use(compression({
filter: (req, res) => {
if (req.headers['x-no-compression']) {
return false;
}
return compression.filter(req, res);
},
threshold: 1024, // Only compress responses larger than 1KB
level: 6, // Compression level (0-9)
}));
Compression with Fastify
As shown in the Fastify example above, register @fastify/compress directly on the Fastify instance. Fastify's compression plugin supports Brotli, gzip, and deflate encodings out of the box.
Caching Strategies
Caching eliminates redundant computations and database queries, dramatically reducing response times for frequently accessed data. NestJS provides built-in cache management through @nestjs/cache-manager.
Setting Up In-Memory Cache
npm install @nestjs/cache-manager cache-manager
// app.module.ts
import { Module } from '@nestjs/common';
import { CacheModule, CacheStore } from '@nestjs/cache-manager';
import { memoryStore } from 'cache-manager';
@Module({
imports: [
CacheModule.register({
store: memoryStore as unknown as CacheStore,
ttl: 60000, // Default TTL: 60 seconds
max: 100, // Maximum number of items in cache
}),
UsersModule,
],
})
export class AppModule {}
Using Cache Interceptors
// users.controller.ts
import { Controller, Get, UseInterceptors, CacheTTL } from '@nestjs/common';
import { CacheInterceptor } from '@nestjs/cache-manager';
@Controller('users')
export class UsersController {
@Get()
@UseInterceptors(CacheInterceptor)
@CacheTTL(30000) // Override TTL for this endpoint: 30 seconds
async findAll() {
// This result will be cached
return this.userService.findAll();
}
@Get(':id')
@UseInterceptors(CacheInterceptor)
async findOne(@Param('id') id: string) {
return this.userService.findOne(id);
}
}
Custom Cache Key Generation
By default, the cache interceptor uses the request URL as the key. For more granular control, implement a custom key generator:
import { Injectable } from '@nestjs/common';
import { CacheKey } from '@nestjs/cache-manager';
@Injectable()
export class CustomCacheKeyGenerator {
generateKey(context: any): string {
const request = context.switchToHttp().getRequest();
const userRole = request.user?.role || 'anonymous';
const url = request.url;
return `${userRole}:${url}`;
}
}
// Apply in controller
@Get('products')
@UseInterceptors(CacheInterceptor)
@CacheKey('custom-products-key')
async getProducts() {
return this.productService.findAll();
}
Distributed Caching with Redis
For multi-instance deployments, use Redis as a shared cache store:
npm install cache-manager-redis-yarn-ts
import { CacheModule, CacheStore } from '@nestjs/cache-manager';
import { redisStore } from 'cache-manager-redis-yarn-ts';
@Module({
imports: [
CacheModule.registerAsync({
useFactory: async () => ({
store: await redisStore({
socket: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
},
ttl: 60000,
}) as unknown as CacheStore,
}),
}),
],
})
export class AppModule {}
Lazy Loading Modules
Lazy loading defers the initialization of non-critical modules until they are actually needed, reducing startup time and memory footprint. This is particularly valuable for large applications with many feature modules.
// main.ts - Configure lazy loading routes
import { LazyModuleLoader } from '@nestjs/core';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const lazyModuleLoader = app.get(LazyModuleLoader);
// Register a lazy-loaded module on a specific route
const lazy = await app.getHttpAdapter().getInstance();
// Define routes that trigger lazy loading
// This works with both Express and Fastify adapters
}
// Define a lazy-loadable module
@Module({
imports: [],
controllers: [ReportsController],
providers: [ReportsService],
})
export class ReportsModule {
// This module will only load when first accessed
}
// In your main module, use route-based lazy loading
@Module({
imports: [
// Regular eager-loaded modules
UsersModule,
// Lazy-loaded modules are configured at the router level
],
})
export class AppModule {}
To implement lazy loading with the router, you can use the LazyModuleLoader service directly:
// app.controller.ts
import { Controller, Get, Req } from '@nestjs/common';
import { LazyModuleLoader } from '@nestjs/core';
import { Request } from 'express';
@Controller()
export class AppController {
constructor(private lazyModuleLoader: LazyModuleLoader) {}
@Get('reports')
async getReports(@Req() request: Request) {
// Dynamically load the ReportsModule when this route is hit
const moduleRef = await this.lazyModuleLoader.load(() =>
import('./reports/reports.module').then(m => m.ReportsModule),
);
const reportsController = moduleRef.get('ReportsController');
return reportsController.generate();
}
}
Optimizing Database Queries
Database interactions are often the primary bottleneck in NestJS applications. Optimizing queries, using proper indexing, and leveraging ORM features effectively can yield massive performance gains.
TypeORM Optimizations
// Select only needed columns instead of SELECT *
const users = await this.userRepository.find({
select: ['id', 'name', 'email'], // Only fetch required fields
where: { isActive: true },
take: 20, // Pagination
skip: 0,
});
// Use query builder for complex queries with proper indexing hints
const result = await this.userRepository
.createQueryBuilder('user')
.leftJoinAndSelect('user.profile', 'profile')
.select(['user.id', 'user.name', 'profile.avatarUrl'])
.where('user.status = :status', { status: 'active' })
.orderBy('user.createdAt', 'DESC')
.limit(20)
.getMany();
// Enable query result caching (TypeORM cache)
const cachedUsers = await this.userRepository.find({
where: { role: 'admin' },
cache: {
id: 'admin_users',
milliseconds: 30000, // 30 seconds
},
});
Prisma Optimizations
// Use select to fetch only needed fields
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
profile: {
select: {
avatarUrl: true,
},
},
},
where: { isActive: true },
take: 20,
skip: 0,
});
// Use batch operations for bulk inserts/updates
const createMany = await prisma.user.createMany({
data: userArray,
skipDuplicates: true,
});
// Enable interactive transactions for consistency
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.update({
where: { id: userId },
data: { balance: { decrement: amount } },
});
const transaction = await tx.transaction.create({
data: { userId, amount, type: 'withdrawal' },
});
return { user, transaction };
});
Connection Pooling
// TypeORM connection pooling configuration
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432'),
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
extra: {
max: 20, // Maximum pool size
min: 5, // Minimum pool size
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
},
synchronize: false,
logging: ['error', 'warn'], // Disable verbose logging in production
});
Leveraging Streams for Large Payloads
When dealing with large datasets, streaming prevents loading entire result sets into memory. NestJS integrates seamlessly with Node.js streams.
import { Controller, Get, Res, StreamableFile } from '@nestjs/common';
import { Response } from 'express';
import { Readable } from 'stream';
@Controller('exports')
export class ExportsController {
@Get('large-report')
async downloadReport(@Res({ passthrough: true }) res: Response): Promise<StreamableFile> {
const readableStream = new Readable({
read() {
// Push data chunks as they become available
// This could come from a database cursor, file read, etc.
},
});
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', 'attachment; filename="report.csv"');
return new StreamableFile(readableStream);
}
@Get('stream-json')
@Header('Content-Type', 'application/json')
async streamLargeDataset(@Res() res: Response) {
const cursor = await this.dataService.getCursor();
// Stream JSON array manually to avoid buffering
res.write('{"data":[');
let first = true;
for await (const record of cursor) {
if (!first) res.write(',');
res.write(JSON.stringify(record));
first = false;
}
res.write('],"total":1000}');
res.end();
}
}
Cluster Mode and Process Management
Node.js runs on a single thread. To utilize multi-core CPUs, you need to spawn multiple processes. Use PM2 or Node.js cluster module for this.
PM2 Configuration
// ecosystem.config.js
module.exports = {
apps: [
{
name: 'nestjs-app',
script: 'dist/main.js',
instances: 'max', // One instance per CPU core
exec_mode: 'cluster',
watch: false,
max_memory_restart: '500M',
env: {
NODE_ENV: 'production',
PORT: 3000,
},
env_production: {
NODE_ENV: 'production',
PORT: 3000,
},
},
],
};
Run with: pm2 start ecosystem.config.js --env production
Native Cluster Module
// main.ts with cluster support
import cluster from 'node:cluster';
import { availableParallelism } from 'node:os';
import { Injectable, Logger } from '@nestjs/common';
const numCPUs = availableParallelism();
async function bootstrap() {
if (cluster.isPrimary) {
Logger.log(`Primary process ${process.pid} is running`);
// Fork workers equal to CPU cores
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker) => {
Logger.warn(`Worker ${worker.process.pid} died. Restarting...`);
cluster.fork();
});
} else {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
Logger.log(`Worker ${process.pid} started on port 3000`);
}
}
bootstrap();
Optimizing Asynchronous Operations
Proper async handling prevents blocking the event loop. Use Promise.all, Promise.allSettled, and avoid synchronous operations in request handlers.
// Bad: Sequential await calls
@Get('dashboard')
async getDashboard() {
const users = await this.userService.count(); // Wait 200ms
const orders = await this.orderService.count(); // Wait 150ms
const revenue = await this.orderService.revenue(); // Wait 180ms
// Total: ~530ms
return { users, orders, revenue };
}
// Good: Parallel execution with Promise.all
@Get('dashboard')
async getDashboard() {
const [users, orders, revenue] = await Promise.all([
this.userService.count(),
this.orderService.count(),
this.orderService.revenue(),
]);
// Total: ~200ms (max of the three)
return { users, orders, revenue };
}
// Handling independent promises with different error behaviors
@Get('aggregate')
async getAggregate() {
const results = await Promise.allSettled([
this.serviceA.fetch(), // Might fail
this.serviceB.fetch(), // Might fail
this.serviceC.fetch(), // Might fail
]);
return results.map((result, index) => ({
source: index,
success: result.status === 'fulfilled',
data: result.status === 'fulfilled' ? result.value : null,
error: result.status === 'rejected' ? result.reason?.message : null,
}));
}
Queue-Based Processing for Heavy Tasks
Offload CPU-intensive or long-running tasks to background queues using @nestjs/bull, keeping your HTTP response times fast.
npm install @nestjs/bull bull
// bull.config.ts
import { BullModule } from '@nestjs/bull';
@Module({
imports: [
BullModule.forRoot({
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
},
}),
BullModule.registerQueue({
name: 'email',
}),
BullModule.registerQueue({
name: 'image-processing',
limiter: {
max: 10, // Max 10 jobs per time window
duration: 1000, // Per second
},
}),
],
})
export class AppModule {}
// email.processor.ts
import { Processor, Process } from '@nestjs/bull';
import { Job } from 'bull';
@Processor('email')
export class EmailProcessor {
@Process('send-welcome')
async handleWelcomeEmail(job: Job<{ email: string; name: string }>) {
const { email, name } = job.data;
// Simulate slow email sending
await this.emailService.sendWelcome(email, name);
// Track progress for long-running jobs
await job.progress(50);
await this.emailService.sendFollowUp(email);
await job.progress(100);
return { sent: true, email };
}
}
// In your controller
@Controller('auth')
export class AuthController {
constructor(
@InjectQueue('email') private emailQueue: Queue,
) {}
@Post('register')
async register(@Body() dto: RegisterDto) {
// Create user synchronously
const user = await this.userService.create(dto);
// Offload email sending to background queue
await this.emailQueue.add('send-welcome', {
email: user.email,
name: user.name,
}, {
delay: 1000, // Delay 1 second
attempts: 3, // Retry up to 3 times
backoff: {
type: 'exponential',
delay: 2000,
},
removeOnComplete: true,
});
return { userId: user.id, message: 'Registration successful' };
}
}
Response Serialization Optimization
NestJS serialization can be optimized by excluding unnecessary properties and using class-transformer efficiently.
// user.entity.ts
import { Exclude, Expose, Transform } from 'class-transformer';
@Exclude() // Exclude all by default, only expose what's needed
export class UserEntity {
@Expose()
id: string;
@Expose()
name: string;
@Expose()
email: string;
@Expose({ groups: ['admin'] })
internalNotes: string;
@Expose()
@Transform(({ value }) => value?.toISOString())
createdAt: Date;
// This will never be serialized
passwordHash: string;
// This will never be serialized
refreshToken: string;
}
// controller
@Controller('users')
export class UsersController {
@Get(':id')
@UseInterceptors(ClassSerializerInterceptor)
async findOne(@Param('id') id: string) {
const user = await this.userService.findOne(id);
return new UserEntity(user);
}
@Get(':id/admin')
@UseInterceptors(ClassSerializerInterceptor)
@SerializeOptions({ groups: ['admin'] })
async findOneAdmin(@Param('id') id: string) {
const user = await this.userService.findOne(id);
return new UserEntity(user);
}
}
Middleware and Guard Optimizations
Minimize work in middleware and guards that run on every request. Use lightweight synchronous checks where possible.
// Optimized guard - avoid async when not needed
@Injectable()
export class FastAuthGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
// Fast path: check a simple header without DB queries
const apiKey = request.headers['x-api-key'];
if (apiKey && apiKey === process.env.INTERNAL_API_KEY) {
return true;
}
// Check for cached token validation
const token = this.extractToken(request);
if (!token) return false;
// Use cached validation, not a DB call per request
return this.authService.validateTokenSync(token);
}
}
// Lightweight logging middleware
@Injectable()
export class PerformanceLoggingMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: () => void) {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
if (duration > 100) { // Only log slow requests
console.warn(`[SLOW] ${req.method} ${req.url} - ${duration}ms`);
}
});
next();
}
}
GraphQL Performance Optimizations
NestJS GraphQL applications benefit from DataLoader to prevent the N+1 query problem, query complexity limits, and persisted queries.
npm install dataloader graphql-query-complexity
// data-loader.service.ts
import { Injectable } from '@nestjs/common';
import DataLoader from 'dataloader';
@Injectable()
export class DataLoaderService {
constructor(private userService: UserService) {}
createUserLoader() {
return new DataLoader<string, User>(async (ids: string[]) => {
// Batch load all users in a single query
const users = await this.userService.findByIds(ids as string[]);
// Preserve the order of IDs
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id) || null);
}, {
cache: true,
maxBatchSize: 100,
});
}
}
// user.resolver.ts
@Resolver(() => User)
export class UserResolver {
private userLoader: DataLoader<string, User>;
constructor(private dataLoaderService: DataLoaderService) {
this.userLoader = this.dataLoaderService.createUserLoader();
}
@ResolveField(() => [Post])
async posts(@Parent() user: User) {
// Use the loader to batch-fetch posts
return this.postLoader.load(user.id);
}
}
// Complexity plugin for GraphQL
@Module({
imports: [
GraphQLModule.forRoot({
autoSchemaFile: 'schema.gql',
plugins: [
{
requestDidStart: () => ({
didResolveOperation: (ctx) => {
const complexity = ctx.request.complexity;
const maxComplexity = 1000;
if (complexity > maxComplexity) {
throw new Error(
`Query too complex: ${complexity}. Maximum allowed: ${maxComplexity}`,
);
}
},
}),
},
],
}),
],
})
export class GraphQLConfigModule {}
Memory Management and Leak Prevention
Monitor memory usage and prevent leaks by properly managing subscriptions, timers, and event listeners.
// Use onModuleDestroy to clean up resources
@Injectable()
export class WebSocketService implements OnModuleDestroy {
private intervals: NodeJS.Timer[] = [];
private subscriptions: Subscription[] = [];
constructor() {
// Track subscriptions for cleanup
const sub = this.eventEmitter.on('user.created', this.handleUserCreated);
this.subscriptions.push(sub);
}
startPolling() {
const interval = setInterval(() => {
this.checkUpdates();
}, 5000);
this.intervals.push(interval);
}
async onModuleDestroy() {
// Clear all intervals
this.intervals.forEach(clearInterval);
// Unsubscribe from all event emitters
this.subscriptions.forEach(sub => sub.unsubscribe());
// Close any persistent connections
await this.closeConnections();
}
}
// Memory-efficient bulk processing
@Injectable()
export class DataProcessor {
async processLargeDataset() {
const batchSize = 1000;
let offset = 0;
let hasMore = true;
while (hasMore) {
const batch = await this.repository.find({
take: batchSize,
skip: offset,
});
if (batch.length === 0) {
hasMore = false;
break;
}
// Process batch
await this.processBatch(batch);
// Explicitly allow GC by clearing references
batch.length = 0;
offset += batchSize;
}
}
}
Benchmarking Your NestJS Application
Regular benchmarking is essential to validate optimizations and catch regressions. Use tools like autocannon, wrk, or k6 for load testing.
Autocannon Benchmark Example
npm install -g autocannon
# Basic benchmark: 100 connections, 10 seconds
autocannon -c 100 -d 10 http://localhost:3000/api/users
# With payload and detailed output
autocannon -c 50 -d 30 -m POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJhbGciOi..." \
-b '{"name":"test","email":"test@example.com"}' \
http://localhost:3000/api/users
# Output includes:
# - Avg latency
# - Requests per second
# - Percentile distributions (p99, p99.9)
# - Error rate
Programmatic Benchmark Setup
// benchmark.ts - Run as a separate script
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import autocannon from 'autocannon';
async function runBenchmark() {
const app = await NestFactory.create(AppModule);
await app.listen(3001);
const results = await autocannon({
url: 'http://localhost:3001/api/benchmark-endpoint',
connections: 100,
pipelining: 2,
duration: 10,
timeout: 30,
});
console.log('=== Benchmark Results ===');
console.log(`Avg Latency: ${results.latency.average}ms`);
console.log(`P99 Latency: ${results.latency.p99}ms`);
console.log(`Requests/sec: ${results.requests.average}`);
console.log(`Throughput: ${(results.throughput.average / 1024 / 1024).toFixed(2)} MB/s`);
console.log(`Errors: ${results.errors}`);
await app.close();
process.exit(0);
}
runBenchmark();
Continuous Performance Monitoring
// performance.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class PerformanceInterceptor implements NestInterceptor {
private metrics = new Map<string, { count: number; totalMs: number; min: number; max: number }>();
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const handlerName = `${context.getClass().name}.${context.getHandler().name}`;
const start = process.hrtime.bigint();
return next.handle().pipe(
tap(() => {
const duration = Number(process.hrtime.bigint() - start) / 1_000_000; // ms
const current = this.metrics.get(handlerName) || { count: 0, totalMs: 0, min: Infinity, max: -Infinity };
current.count++;
current.totalMs += duration;
current.min = Math.min(current.min, duration);
current.max = Math.max(current.max, duration);
this.metrics.set(handlerName, current);
// Log every 1000 requests
if (current.count % 1000 === 0) {
console.log(`[METRICS] ${handlerName}: avg=${(current.totalMs / current.count).toFixed(2)}ms, min=${current.min.toFixed(2)}ms, max=${current.max.toFixed(2)}ms, count=${current.count}`);
}
}),
);
}
}
Best Practices Summary
- Start with Fastify — The switch from Express to Fastify alone can double your throughput with minimal code changes.
- Cache aggressively but intelligently — Use cache-manager with Redis for distributed caching, and invalidate caches on data mutations.
- Batch and parallelize — Use
Promise.allfor independent async operations and DataLoader for GraphQL batching. - Stream large responses — Never load entire large datasets into memory; use streams and cursors.
- Offload heavy work — Use Bull queues for CPU-intensive tasks, email sending, and image processing.
- Monitor continuously — Implement performance interceptors, use PM2 monitoring, and set up alerts for latency spikes.
- Optimize database access — Select only needed columns, use connection pooling, and add proper indexes.
- Run in cluster mode — Utilize all CPU cores with PM2 or the native cluster module.
- Compress responses — Enable Brotli/gzip compression, especially for text-based responses like JSON.
- Clean up resources — Implement
OnModuleDestroyto prevent memory leaks from timers and event listeners. - Benchmark before and after — Use autocannon or wrk to establish baseline metrics and validate each optimization.
- Lazy load non-critical modules — Reduce startup time and memory footprint for large applications.
Conclusion
NestJS performance optimization is a multi-faceted discipline that spans HTTP server selection, caching architectures, database query patterns, concurrency management, and operational practices. By methodically applying the techniques covered in this tutorial — from the foundational switch to Fastify, through strategic caching and queue-based processing, to continuous benchmarking and monitoring — you can build NestJS applications that serve thousands of concurrent users with sub-millisecond response times while maintaining efficient resource utilization. The framework's modular design and rich ecosystem of performance-oriented packages make it well-suited for high-throughput production environments. Remember that optimization is iterative: measure first, apply changes incrementally, benchmark again, and always keep your specific use case and traffic patterns in mind when prioritizing which techniques to implement.