NestJS tutorials do a good job on controllers, providers, guards, and pipes. The next layer up, the operational patterns that keep the app healthy under real production load, gets less attention. This is a working list of ten of those patterns, with code short enough to read on a phone.
1. Lazy-load feature modules to shrink cold starts
When Nest boots, it walks the whole dependency graph and instantiates every provider before the first request lands. On a Lambda or Cloud Run cold start that walk shows up on p95. If most of the graph is admin-only or rare-path code, deferring it makes the first request cheaper.
The first admin request pays the import cost. Everything else on /admin/* reuses the cached module.
// admin-lazy.middleware.ts
@Injectable()
export class AdminLazyMiddleware implements NestMiddleware {
private ready: Promise<void> | null = null;
constructor(private moduleRef: ModuleRef) {}
async use(_req: Request, _res: Response, next: NextFunction) {
if (!this.ready) {
this.ready = (async () => {
const { AdminModule } = await import('./admin.module');
await this.moduleRef.create(AdminModule);
})();
}
await this.ready;
next();
}
}2. Request-scoped services when isolation matters more than throughput
Global singletons keep cache pools warm, which is what you want most of the time. In a multi-tenant surface, they also give every request access to every tenant's memoized data. Scope-per-request fixes that.
The trade is real. Every request-scoped provider forces its dependency chain to rebuild on the request. Reach for it only where the data actually is per-tenant or per-user.
@Injectable({ scope: Scope.REQUEST })
export class TenantCache {
private store = new Map<string, unknown>();
get<T>(key: string) { return this.store.get(key) as T | undefined; }
set(key: string, value: unknown) { this.store.set(key, value); }
}3. AsyncLocalStorage for request context in async paths
Interceptors give you request context inside the controller, but as soon as you touch a queue worker, a timeout callback, or a lazy promise, the context is gone. AsyncLocalStorage carries it through the event loop.
Any code inside the request can now call requestContext.getStore() and get the tenant back without threading a parameter through five layers.
import { AsyncLocalStorage } from 'node:async_hooks';
export const requestContext = new AsyncLocalStorage<{
requestId: string;
tenantId: string;
}>();
@Injectable()
export class ContextInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
const req = context.switchToHttp().getRequest();
return new Observable((sub) => {
requestContext.run(
{ requestId: req.id, tenantId: req.headers['x-tenant'] as string },
() => next.handle().subscribe(sub),
);
});
}
}4. gRPC health checks so probes match protocol
Kubernetes HTTP probes and gRPC probes both need the event loop responsive. gRPC probes are useful when the actual service is gRPC: you get the same failure surface for both traffic and probes, and there is a shared health protocol so tools like grpc_health_probe and the Kubernetes gRPC prober work out of the box.
@Controller()
export class HealthController {
@GrpcMethod('grpc.health.v1.Health', 'Check')
check(): { status: 'SERVING' | 'NOT_SERVING' } {
return { status: 'SERVING' };
}
}livenessProbe:
grpc:
port: 50051
service: grpc.health.v1.Health
initialDelaySeconds: 5
periodSeconds: 105. Selective serialization for smaller cache payloads
ClassSerializerInterceptor runs off class-transformer, and class-transformer supports groups. Give clients a way to name the fields they want, and cache the projection instead of the full entity.
The Redis line saves bytes and CPU: smaller payloads, no full-entity deserialization on the hot path.
class User {
@Expose({ groups: ['id'] }) id!: string;
@Expose({ groups: ['email'] }) email!: string;
@Expose({ groups: ['profile'] }) profile!: Profile;
}
@Injectable()
export class ProjectedSerializerInterceptor extends ClassSerializerInterceptor {
serialize(response: unknown, options: ClassTransformOptions) {
const fields = this.getRequest().query.fields?.toString().split(',');
return super.serialize(response, { ...options, groups: fields });
}
}6. TCP tuning so kernel limits don't hit before app limits
Node's default HTTP backlog is 511, net.core.somaxconn on many Linux images is 128, and tcp_tw_reuse is off. Under a burst you get connection refused before Nest ever sees the request.
Make sure the container is running with the sysctl actually applied. Many orchestrators sandbox sysctl writes; check with sysctl net.core.somaxconn after boot.
RUN printf "net.core.somaxconn=65535\nnet.ipv4.tcp_tw_reuse=1\n" \
>> /etc/sysctl.d/99-app.confasync function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000, '0.0.0.0');
}7. DataLoader in the GraphQL resolver, request-scoped
@ResolveField fires per parent, so ten posts asking for .author end up ten queries deep. DataLoader batches inside a single tick and returns keyed results.
Per-request scope keeps the batch fresh. Skip the always-warm version; it caches stale data across users.
@Injectable({ scope: Scope.REQUEST })
export class UserLoader {
constructor(private users: UserService) {}
createLoader() {
return new DataLoader<string, User>(async (ids) => {
const rows = await this.users.findByIds([...ids]);
const byId = new Map(rows.map((r) => [r.id, r]));
return ids.map((id) => byId.get(id) ?? null);
});
}
}
@ResolveField('author')
author(
@Parent() post: Post,
@Context() ctx: { userLoader: DataLoader<string, User> },
) {
return ctx.userLoader.load(post.authorId);
}8. Binary payloads on high-volume WebSocket streams
JSON is fine most of the time. When your gateway is shovelling twenty thousand messages a second at a browser tab, the encode cost adds up. Protobuf gives you a schema and a smaller wire size.
Same story for the client: keep a schema copy and decode into a real object once. Do not send Protobuf to code you cannot upgrade in lockstep.
message Event {
uint64 id = 1;
string kind = 2;
bytes payload = 3;
}export class ProtoAdapter extends WsAdapter {
serialize(event: string, payload: any) {
return Event.encode({
id: BigInt(payload.id),
kind: event,
payload: encode(payload),
}).finish();
}
}9. Distributed locks around cron so N pods do not all run the job
A @Cron on a Deployment with three replicas runs three times. Redis with Redlock across a few nodes gives you a lock with a TTL that fails safe.
Pick the TTL longer than your worst-case run. If the job overruns, the lock releases and the next tick can pick it up.
@Injectable()
export class NightlyJob {
constructor(private redlock: Redlock) {}
@Cron('0 2 * * *')
async run() {
let lock;
try {
lock = await this.redlock.acquire(['cron:nightly'], 60_000);
await this.doTheWork();
} catch (err: any) {
if (err?.name !== 'ExecutionError') throw err;
} finally {
await lock?.release();
}
}
}10. Rate limit at the edge, not in the handler
Application-level rate limiting still costs you request handling. XDP runs the drop in the kernel, before Node touches the packet. This is worth it when you actually need it, and the setup is not free, so start with a CDN or an Nginx rule first.
Load the program with bpftool or libbpf, keep a userspace controller that resets the map on a timer, and monitor drop counts before you tune the threshold.
// xdp_ratelimit.bpf.c (sketch)
SEC("xdp")
int xdp_rate_limit(struct xdp_md *ctx) {
__u64 saddr = ipv4_src(ctx);
__u32 *count = bpf_map_lookup_elem(&rate_limits, &saddr);
__u32 one = 1;
if (count) {
if (*count > 100) return XDP_DROP;
__sync_fetch_and_add(count, 1);
} else {
bpf_map_update_elem(&rate_limits, &saddr, &one, BPF_ANY);
}
return XDP_PASS;
}Every one of these ships value in a different way, and none of them is the whole answer. The interesting production work in NestJS mostly happens outside the framework: async context, kernel knobs, cache shapes, and the boring lock around a cron job. Pick the two that hurt in your app first.