Lazy-loading Modules
Keep a module out of the startup graph and load it with LazyModuleLoader the first time a request needs it, then reuse it from the cache.
What you'll learn
- Load a module on demand with LazyModuleLoader and a dynamic import(), and take providers from the module reference it returns
- Explain what the cache does on the second load, and read the console to see when the module was initialised
- List what a lazily loaded module cannot contain: controllers, lifecycle hooks, global providers
Nest builds the whole module graph at startup: every module reachable from the root is initialised, every provider constructed, before app.listen() returns. That is what makes the first request as fast as the thousandth, and for a server that runs for days it is the right trade. It is the wrong one for code that runs briefly and often: a serverless function that pays the whole boot on every cold start, a worker that handles one kind of job per invocation, a webhook receiver where each event touches a different corner of the application. For those, Nest can leave a module out of the graph and load it the first time it is needed.
The loader
LazyModuleLoader from @nestjs/core is a provider like any other:
@Injectable()
export class JobsService {
constructor(private readonly lazyModuleLoader: LazyModuleLoader) {}
}
Outside the container, app.get(LazyModuleLoader) in main.ts gives the same object. Loading takes two steps, and both matter:
const { InvoicesModule } = await import('./invoices/invoices.module');
const moduleRef = await this.lazyModuleLoader.load(() => InvoicesModule);
The first line is JavaScript's own dynamic import(). It is what keeps the module out of the startup graph: a static import at the top of the file would pull the module class in when the file loads, and Nest, seeing nothing import it, still would not initialise it, but the code would already be in memory and the point would be lost. The second line hands the class to Nest, which builds the module as it would have at startup, providers and all, and returns a ModuleRef for it. load() takes a function returning the class, the same shape as forwardRef(), for the same reason: the reference is read when Nest needs it.
Take providers from the returned reference, importing their classes lazily too:
const { InvoicesService } = await import('./invoices/invoices.service');
const invoices = moduleRef.get(InvoicesService);
The module itself is ordinary. Nothing in @Module() says it will be lazy; only the absence of an importer does.
The cache
The first load() of a module builds it. Every later load() of the same class returns the same module from a cache, in microseconds, with the same provider instances. A lazily loaded module is therefore still a singleton graph, built later; it is not rebuilt per call. The console shows the difference: [InstanceLoader] InvoicesModule dependencies initialized prints when the first request arrives, not at boot, and never again.
What cannot be lazy
Three limits follow from loading after startup. A lazily loaded module cannot contain controllers, or GraphQL resolvers or gateways, because routes are registered once, at startup, and Nest cannot add a route to a running server. Lifecycle hooks in the module do not run: onModuleInit and the rest belong to the boot sequence, which has already happened. And global modules and global enhancers (guards, pipes, interceptors registered with APP_GUARD and friends) do not apply to it, for the same reason. A lazy module is for providers: services, repositories, clients.
Your task
The report for the shelter is expensive to set up and rarely asked for, so ReportModule is not imported by anything.
- Give
CatsControllertheLazyModuleLoader. - In the
reporthandler, import the report module at that moment, not at the top of the file, load it through the loader, takeReportServicefrom the module reference, and build the report from the cats.
Run and read the console. ReportModule dependencies initialized appears after the first GET /cats/report, not among the boot lines, and the second report reuses the instance.
When it fails
ReportModule dependencies initializedprints at boot: the module is imported statically, or listed in some module'simports. Remove both; the dynamicimport()inside the handler is the only reference.Nest could not find ReportService element:get()was called on the wrong reference, probably the controller's ownModuleRef. Providers of a lazy module come from theModuleRefthatload()returned.instancegrows with every report: the module is being built each time.load()caches by class; a new class expression, ormoduleRef.create(), defeats that.Nest can't resolve dependencies of the CatsController (CatsService, ?):LazyModuleLoaderis imported from the wrong package. It lives in@nestjs/core.
Remember
- Leave a module out of every
importslist, and it is not built at startup. await import('./x.module')thenlazyModuleLoader.load(() => XModule): the first keeps the code out of the graph, the second builds the module and returns itsModuleRef.- Loaded once, cached forever: a lazy module is a singleton graph built late.
- No controllers, no lifecycle hooks, no global enhancers in a lazy module.
Stuck? Show a hint
Add LazyModuleLoader to the constructor. In report(): const { ReportModule } = await import('../reports/report.module'); const moduleRef = await this.lazyModuleLoader.load(() => ReportModule); const { ReportService } = await import('../reports/report.service'); then moduleRef.get(ReportService).build(...).