Efficient Database Scaling in NestJS with TypeORM: Best Practices for Enforcing Slave Reads

Scaling database reads is a critical challenge in modern applications. By default, NestJS with TypeORM sends all queries to the master database, which can cause performance bottlenecks.
A better strategy is to use read replicas (slave databases) for queries, while keeping writes on the master database. However, enforcing slave reads in TypeORM is not straightforward.
Problem Statement:
TypeORM supports automatic read queries from slave databases. However, in a write-heavy system where defaultMode
is set to master
, most UPDATE
and POST
requests must read from the master database to ensure real-time updated data. Some GET
APIs, especially those involving large datasets with multiple table joins, should explicitly read from the slave database to improve performance. This blog explores different approaches to solving this problem and explains why the chosen method is the most effective.
- TypeORM supports automatic read queries from the slave database, but when
defaultMode
is set tomaster
(as in write-heavy systems), read queries may still go to the master. In such cases, explicit routing to the slave database is needed for specific read-heavy queries. - Read queries may still hit the master DB, overloading it.
- We need a scalable, maintainable solution that ensures all read operations explicitly use the slave DB.
Setting Up TypeORM with Replication
To enable automatic read queries from the slave database while keeping writes on the master, configure TypeORM with replication. Below is a sample configuration for a PostsService
in a NestJS application.
📌 TypeORM Configuration with Replication
TypeOrmModule.forRoot({
type: 'mysql',
replication: {
defaultMode: "master",
master: {
host: process.env.DB_MASTER_HOST,
port: Number(process.env.DB_MASTER_PORT),
username: process.env.DB_MASTER_USER,
password: process.env.DB_MASTER_PASS,
database: process.env.DB_NAME,
},
slaves: [
{
host: process.env.DB_SLAVE_HOST,
port: Number(process.env.DB_SLAVE_PORT),
username: process.env.DB_SLAVE_USER,
password: process.env.DB_SLAVE_PASS,
database: process.env.DB_NAME,
},
],
},
entities: [Post],
synchronize: false,
logging: false,
});
Using TypeORM in a Module
To ensure that repositories use the correct connection, import TypeOrmModule.forFeature
in the PostsModule
.
📌 PostsModule
@Module({
imports: [
TypeOrmModule.forFeature([Post]), // Uses default master/slave replication setup
],
controllers: [PostsController],
providers: [PostsService],
})
export class PostsModule {}
🔹 Different Ways to Enforce Slave Reads in NestJS
1️⃣ Using QueryRunner for Manual Slave Reads
One common approach is to use QueryRunner
to manually create a connection to the slave.
📌 Example: QueryRunner for Slave Reads
import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
@Injectable()
export class DatabaseService {
constructor(private readonly dataSource: DataSource) {} async getReadManager(): Promise<EntityManager> {
const queryRunner = this.dataSource.createQueryRunner('slave');
await queryRunner.connect();
return queryRunner.manager;
}
}
📌 Usage in PostService
const readManager = await this.databaseService.getReadManager();
const data = await readManager.find(Posts, {});
✅ Pros:
- Ensures queries run on the slave database.
- Works for both simple and complex queries.
❌ Cons:
- Requires manual handling of
QueryRunner
. - Can lead to connection leaks if not properly released.
- Adds boilerplate to every read query.
2️⃣ Using a Separate Repository for Slave Reads
Another approach is to inject a separate repository that explicitly connects to the slave DB.
📌 Example: Injecting Slave Repository
@InjectRepository(Posts, 'slave')
private readonly PostsRepository: Repository<Posts>;
✅ Pros:
- Makes read queries explicit.
- No need for QueryRunner.
❌ Cons:
- Requires duplicate repositories (one for master, one for slave).
- Hard to maintain as the project scales.
3️⃣ The Best Approach: Using an Interceptor for Automatic Slave Reads
The cleanest and most scalable way is to use a NestJS interceptor that automatically enforces slave reads.
📌 ReadSlaveInterceptor
(Forcing Reads on Slave)
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { DataSource, EntityManager } from 'typeorm';
import { map } from 'rxjs/operators';
@Injectable()
export class ReadSlaveInterceptor implements NestInterceptor {
constructor(private readonly dataSource: DataSource) {} async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const queryRunner = this.dataSource.createQueryRunner('slave');
await queryRunner.connect();
try {
const readManager: EntityManager = queryRunner.manager;
const request = context.switchToHttp().getRequest();
request.readManager = readManager; return next.handle().pipe(
map((data) => data)
);
} finally {
await queryRunner.release();
}
}
}
📌 Applying Interceptor in PostService
import { Injectable, UseInterceptors } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { ReadSlaveInterceptor } from '@common/interceptors/read-slave.interceptor';
@Injectable()
@UseInterceptors(ReadSlaveInterceptor)
export class PostService {
constructor() {} public async getPosts(request: any): Promise<any> {
const manager: EntityManager = request.readManager;
return await manager.find(Posts, {});
}
}
✅ Pros:
- Automatic enforcement of slave reads.
- No need for manual repository duplication.
- Ensures all reads in a service go to the slave DB.
- No connection leaks (ensures cleanup with
QueryRunner.release()
).
❌ Cons:
- Adds slight overhead due to the interceptor.
- Requires interceptor setup per service.
🔹 Why I Chose the Interceptor Approach
After evaluating multiple approaches, I found interceptors provide the best balance of scalability, maintainability, and performa

💡 Key Takeaways
- QueryRunner works but needs manual cleanup.
- Separate Repositories work but increase complexity.
- Interceptors provide automation, scalability, and maintainability.
🔹 Final Thoughts & Best Practices
✅ Use Interceptors for automatic slave reads.
✅ Use QueryRunner.release()
to prevent connection leaks.
✅ Test Read-After-Write Scenarios to avoid stale data issues.
✅ Monitor DB performance to ensure proper load balancing.
🔥 Conclusion
Enforcing slave reads in NestJS with TypeORM is crucial for scalability and performance. The interceptor approach is the cleanest, most scalable solution, ensuring automatic enforcement without modifying queries.
🚀 What do you think? Let me know in the comments! 🔥