I’ve been thinking a lot about speed. In my work, nothing frustrates users more than a slow application, and nothing stresses a system like inefficient data fetching. Recently, I decided to build an API that could handle complex, connected data without buckling under pressure. The goal was clear: create something fast, scalable, and a joy to develop. This journey led me to combine NestJS, GraphQL, Prisma, and Redis. Let me show you how these tools work together to create a high-performance backend.
The foundation of any solid API is its setup. I began with a new NestJS project. Why NestJS? Its modular structure and dependency injection system make organizing large applications manageable. The first step was installing the core tools.
nest new high-performance-api
cd high-performance-api
npm install @nestjs/graphql @nestjs/apollo graphql @prisma/client prisma
Next, I defined my data. Using Prisma, I described my models in a simple schema file. This acts as a single source of truth for my database.
// schema.prisma
model User {
id String @id @default(cuid())
email String @unique
posts Post[]
}
model Post {
id String @id @default(cuid())
title String
author User @relation(fields: [authorId], references: [id])
authorId String
}
With the schema ready, running npx prisma migrate dev created the database tables. Prisma Client, a type-safe database client, is generated automatically. This means I get full autocompletion and error checking for all my database queries. Have you ever written a database query only to find a typo in a field name at runtime? This setup prevents that entirely.
Now, how do we expose this data? GraphQL is perfect for complex applications because clients can ask for exactly what they need. In NestJS, we use decorators to define GraphQL object types and resolvers.
// post.model.ts
import { ObjectType, Field, ID } from '@nestjs/graphql';
import { User } from './user.model';
@ObjectType()
export class Post {
@Field(() => ID)
id: string;
@Field()
title: string;
@Field(() => User)
author: User;
}
The resolver handles the logic for fetching posts. But here’s a common GraphQL pitfall: the “N+1” problem. If a query fetches 10 posts and their authors, GraphQL might make 1 query for the posts and then 10 separate queries for each author. This is disastrous for performance.
The solution is a tool called DataLoader. It batches multiple requests for individual items into a single request. I created a simple service to load users in batch.
// user.loader.ts
import * as DataLoader from 'dataloader';
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma.service';
@Injectable()
export class UserLoader {
constructor(private prisma: PrismaService) {}
public readonly batchUsers = new DataLoader(async (userIds: string[]) => {
const users = await this.prisma.user.findMany({
where: { id: { in: userIds } },
});
const userMap = new Map(users.map(user => [user.id, user]));
return userIds.map(id => userMap.get(id));
});
}
By injecting this loader into my Post resolver, all requests for user data within a single GraphQL query are combined. This simple pattern can reduce database calls from dozens to just one or two. What if your data doesn’t change often, though? Making a database call is still more work than necessary.
This is where Redis enters the picture. It’s an in-memory data store, perfect for caching. I wanted to cache the results of common queries, like fetching a popular post by its ID.
First, I set up a Redis module in my app.
// app.module.ts
import { RedisModule } from '@nestjs-modules/ioredis';
@Module({
imports: [
RedisModule.forRoot({
type: 'single',
url: 'redis://localhost:6379',
}),
],
})
Then, I created an interceptor. This is a powerful NestJS feature that lets you wrap around a method’s execution. My cache interceptor checks Redis for a stored result before running the database query.
// redis-cache.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { RedisService } from '@nestjs-modules/ioredis';
@Injectable()
export class RedisCacheInterceptor implements NestInterceptor {
constructor(private readonly redisService: RedisService) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const request = context.switchToHttp().getRequest();
const key = `cache:${request.url}`;
const cachedData = await this.redisService.get(key);
if (cachedData) {
return of(JSON.parse(cachedData));
}
return next.handle().pipe(
tap(data => {
this.redisService.set(key, JSON.stringify(data), 'EX', 60); // Cache for 60 seconds
}),
);
}
}
By applying @UseInterceptors(RedisCacheInterceptor) to a resolver, the result is stored in Redis. The next time the same request comes in, it’s served from blazing-fast memory. Think about it: which parts of your application see the same data requested over and over? That’s where caching makes a monumental difference.
Performance isn’t just about speed; it’s also about stability. We must protect our API from being overwhelmed. NestJS guards help here. A simple rate limit guard can prevent abuse.
// rate-limit.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { RedisService } from '@nestjs-modules/ioredis';
@Injectable()
export class RateLimitGuard implements CanActivate {
constructor(private redisService: RedisService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const ip = request.ip;
const key = `rate-limit:${ip}`;
const requests = await this.redisService.incr(key);
if (requests === 1) {
await this.redisService.expire(key, 60); // Reset count after 60 seconds
}
return requests <= 100; // Allow max 100 requests per minute
}
}
Combining these tools—NestJS for structure, Prisma for type-safe data access, DataLoader for efficient fetching, and Redis for caching and rate limiting—creates a formidable stack. The development experience is smooth, and the end result is an API that is robust, maintainable, and incredibly fast.
Building this was more than a technical exercise; it was about crafting a quality experience for both developers and end-users. Each piece solves a real problem we face when data and demand grow. I encourage you to try this setup. Start with a simple model, add a resolver, implement DataLoader for a relation, and then introduce Redis caching. The performance gains are immediate and satisfying.
If you found this walkthrough helpful, please share it with others who might be building their own APIs. What performance challenges have you faced in your projects? Let me know in the comments below—I’d love to hear about your solutions and continue the conversation.