Request Collapsing with NestJS
Preventing duplicate ops under high concurrency using a clean NestJS pattern.

JS geek
Modern APIs are designed for scalability, concurrency, availability, and resilience.
Much of this is achieved through infrastructure and high-level architectural decisions like throttling and rate limiting
But, what happens when:
cache entries expire
multiple valid requests hit the same expensive operation
frontend retries aggressively
GET /users/123
GET /users/123
GET /users/123
.
.
+ 97 more
Effect: DB hit ร 100 ๐ฌ
Solution
Request Collapsing(also called request coalescing): Merge multiple identical concurrent requests into a single upstream call, and the result is shared.
Interceptor based Request Collapsing in NestJS
The Collapse Interceptor: This collapses at HTTP level, not service level.
// collapse.interceptor.ts import { CallHandler, ExecutionContext, Injectable, NestInterceptor, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { from, lastValueFrom } from 'rxjs'; import { COLLAPSE_KEY, CollapseKeyFactory } from './collapse.decorator'; @Injectable() export class CollapseInterceptor implements NestInterceptor { private inFlight = new Map<string, Promise<any>>(); constructor(private reflector: Reflector) {} intercept(context: ExecutionContext, next: CallHandler) { const req = context.switchToHttp().getRequest(); const keyFactory = this.reflector.get<CollapseKeyFactory>( COLLAPSE_KEY, context.getHandler(), ); // No decorator โ normal execution if (!keyFactory) { return next.handle(); } const key = keyFactory(req); if (!this.inFlight.has(key)) { const promise = lastValueFrom(next.handle()).finally(() => { this.inFlight.delete(key); }); this.inFlight.set(key, promise); } return from(this.inFlight.get(key)!); } }The
@Collapse()decorator// collapse.decorator.ts import { SetMetadata } from '@nestjs/common'; export const COLLAPSE_KEY = 'collapse:key'; export type CollapseKeyFactory = (req: any) => string; export const Collapse = (keyFactory: CollapseKeyFactory) => SetMetadata(COLLAPSE_KEY, keyFactory);Register interceptor globally
// main.ts app.useGlobalInterceptors(app.get(CollapseInterceptor));Using
@Collapse()correctly@Get(':id') @Collapse(req => `profile:${req.user.id}:${req.params.id}` ) getUser(@Param('id') id: string) { return this.userService.getUser(id); }
Working
@Collapse()attaches metadata (key)CollapseInterceptor:Builds collapse key
Checks in-flight request map
Executes once
Shares Promise(as result)
Cleans up the queue in
finally
Limitations of this approach
As it is in-memory, it does not work across distributed network - pods.
Risky in case of auth-specific responses
Applications, when should it be used
Cache misses
Expensive DB queries
External API calls
Microservice fan-out
Extra tip
While some would debate about Caching here, but they both are complementary.
Request collapsing should be used together with caching, caching does help in controlling DB hits in case of future requests
Request Collapsing vs Caching
| Feature | Request Collapsing | Caching |
| Works for concurrent requests | โ | โ |
| Works for future requests | โ | โ |
| Prevents stampede | โ | โ |


