Skip to main content

Command Palette

Search for a command to run...

Request Collapsing with NestJS

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

Updated
โ€ข2 min read
Request Collapsing with NestJS

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

  1. 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)!);
       }
     }
    
  2. 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);
    
  3. Register interceptor globally

     // main.ts
     app.useGlobalInterceptors(app.get(CollapseInterceptor));
    
  4. 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

  1. As it is in-memory, it does not work across distributed network - pods.

  2. 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

FeatureRequest CollapsingCaching
Works for concurrent requestsโœ…โŒ
Works for future requestsโŒโœ…
Prevents stampedeโœ…โŒ