We use cookies to enhance your experience on the site
CodeWorlds

API Rate Limiting and the cache layer - controlling port traffic

A port accepts as many ships as it can unload. When more arrive, there are two options: unload faster or let fewer in. An application under load has exactly the same two - cache reduces the work each request costs, and rate limiting caps the number of requests you accept at all.

This lesson closes both sides: first the sentry at the gate, then the storehouse goods are issued from without descending to the depot.

The sentry - ThrottlerModule

NestJS has a built-in mechanism for limiting traffic, so do not write your own counter:

1@Module({
2  imports: [
3    ThrottlerModule.forRoot([
4      {
5        ttl: 60000,
6        limit: 100,
7      },
8    ]),
9  ],
10  providers: [
11    { provide: APP_GUARD, useClass: ThrottlerGuard },
12  ],
13})
14export class AppModule {}

Two numbers describe the whole rule.

ttl
is the window's length in milliseconds - a minute here.
limit
is the number of requests allowed within it. Together: one hundred requests per minute from one address.

Registering through

APP_GUARD
makes
ThrottlerGuard
a global guard - it applies to every route without adding
@UseGuards
to each controller. Once the limit is exceeded the client receives 429 Too Many Requests, and the controller method does not run at all.

Individual routes can be loosened or tightened with a decorator:

1@Post('login')
2@Throttle({ default: { ttl: 60000, limit: 5 } })
3login(@Body() dto: LoginDto) { }

Five login attempts a minute instead of a hundred. This is where rate limiting stops being a performance matter and becomes a safeguard: without it nothing stops password guessing by brute force.

The storehouse - a cache service

The other side is handled by the cache. Wrapping it in your own service gives one place for keys and lifetimes:

1@Injectable()
2export class CacheService {
3  constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}
4
5  async get<T>(key: string): Promise<T | undefined> {
6    return this.cache.get<T>(key);
7  }
8
9  async set<T>(key: string, value: T, ttl = 300): Promise<void> {
10    await this.cache.set(key, value, ttl);
11  }
12
13  async del(key: string): Promise<void> {
14    await this.cache.del(key);
15  }
16}

Three methods cover everything.

get
reads,
set
writes with a lifetime,
del
removes. The default
ttl = 300
can be overridden on any call - short for volatile data, long for lookup tables.

Note

del
- the method easiest to forget while writing and hardest to live without. Without a way to remove a key, the only means of getting rid of stale data is waiting for it to expire.

Automatic caching for endpoints

For ordinary reads you need not write even that.

CacheInterceptor
does it for you:

1@Controller('provinces')
2export class ProvincesController {
3  @Get()
4  @UseInterceptors(CacheInterceptor)
5  @CacheTTL(600)
6  findAll() {
7    return this.provincesService.findAll();
8  }
9}

On every request it goes through five steps, always in this order:

  1. Intercept the HTTP request.
  2. Check the key in the cache.
  3. Run the handler - only on a MISS.
  4. Store the result in the cache.
  5. Return the response to the client.

Step three is the heart of it: on a hit the controller method does not run at all, so there is no database query and no work. The interceptor builds the key from the URL by default, so

/provinces?page=2
and
/provinces?page=3
are two separate entries.

Hence its limitation too: the interceptor handles

GET
requests only. Caching a
POST
makes no sense, since it is meant to change state rather than read it.

Invalidation - the hardest part

Caching is easy until the data changes. When it does, the stale entries must go - and the process has four steps:

  1. Detect the data change in the source (DB).
  2. Identify the cache keys to invalidate.
  3. Remove the stale data from the cache (
    del
    ).
  4. Fetch and store the fresh data in the cache.
1async update(id: number, dto: UpdateProvinceDto) {
2  const province = await this.repo.save({ id, ...dto });
3
4  await this.cacheService.del(`province:${id}`);
5  await this.cacheService.del('provinces:all');
6
7  return province;
8}

Step two causes the most trouble, and the code above shows why. Changing one province invalidates two keys: that province's entry and the list of all of them, because the list contains its data too. With a third page of results and a fifth filter those keys number a dozen - and that is where omissions begin.

So when designing keys, @name, ask yourself the reverse question straight away: not "how do I store this", but "what will I have to remove when it changes". A cache you cannot invalidate is worse than no cache - because it shows stale data and you do not know it.

Summary

The port controls its traffic and issues goods from the storehouse:

  • under load you have two roads: cache reduces the work per request, rate limiting caps the number of requests,
  • ThrottlerModule.forRoot
    is described by two numbers:
    ttl
    (the window in milliseconds) and
    limit
    (requests within it),
  • registering through
    APP_GUARD
    makes
    ThrottlerGuard
    global; exceeding the limit gives the client a 429,
  • @Throttle
    tightens the limit for a single route - on login that is a safeguard, not an optimisation,
  • a cache service wraps three methods:
    get
    ,
    set
    with a configurable TTL, and
    del
    ,
  • CacheInterceptor
    works in five steps
    : intercept the request → check the key → run the handler (on a MISS) → store the result → return the response,
  • on a hit the controller method never runs; the key comes from the URL, so different parameters mean different entries,
  • the interceptor handles
    GET
    only,
  • invalidation in four steps: detect the change in the database → identify the keys → remove the stale ones (
    del
    ) → fetch and store the fresh data,
  • one change usually invalidates several keys - design keys by asking what you will have to remove.

In the next lesson we will turn to compression and response optimisation - what reduces traffic on the way out. For now remember: cache speeds up serving, a limit protects against excess - and the hard part of caching is not the write but knowing when to remove it.

Go to CodeWorlds