DEV Community

bismark66
bismark66

Posted on

The Hidden Cost of Login: How I Decoupled Session Cleanup from Authentication in NestJs

The Hidden Cost of Login: How I Decoupled Session Cleanup from Authentication in NestJs
TL;DR

  • Login looks like two operations — password check + token issue — but there's often a third hiding at the end.
  • bcrypt is intentionally slow (~100ms). Everything else on the login path should be as lean as possible.
  • Running DELETE on expired sessions inside login() creates DB contention under load — users wait for cleanup that has nothing to do with their login succeeding.
  • The fix: move cleanup to a @Cron job using @nestjs/schedule. Login does only what the user is waiting for.
  • The principle: the critical path should contain only what the user is waiting for. Housekeeping belongs elsewhere.

Login seems simple. One bcrypt, one DB write. Done. Except it isn't.

When I built the auth flow for Truckly — a logistics platform I'm working on — I thought authentication was a solved problem. Validate credentials, issue tokens, send response. Standard stuff.

Then I looked more carefully at what was actually happening inside the login() method. There were operations in there that had no business blocking a user's login response. This is the story of finding them, understanding why they mattered, and fixing them.

Breaking Down the Login Path

Here's roughly what the original login flow looked like in NestJs, before the refactor. Each step is sequential — the user waits for all of them:

async login(email: string,password: string,  deviceInfo?: { ipAddress?: string; userAgent?: string; deviceType?: string }) {

// Step 1: Find user by email (fast — indexed column)  
const user = await this.usersService.findOneByEmail(email);

if (!user) throw new UnauthorizedException('User Not Found'); 

const payload = { email: user.email, sub: user.id, userType: user.userType }; 

// Step 2: bcrypt password comparison (~100ms — intentionally slow) const isMatch = await bcrypt.compare(password, user.password); 

if (!isMatch) throw new UnauthorizedException('Invalid credentials');  

// Step 3: Issue tokens, store session in user_sessions table (necessary)  
const accessToken = this.jwtService.sign(payload, { expiresIn: '60m' });  
const refreshToken = this.jwtService.sign(payload, {secret: this.configService.get('JWT_REFRESH_SECRET'), expiresIn: '7d',  });  
await this.createSession(user.id, refreshToken,refreshTokenExpiresAt, ...deviceInfo);  

// Step 4: Clean up expired sessions for this user — THIS WAS THE PROBLEM  
await this.userSessionRepository.createQueryBuilder().delete()    .where('refresh_token_expires_at < :now', { now: new Date() })    .andWhere('user_id = :userId', { userId: user.id }).execute(); 

return { access_token: accessToken, refresh_token: refreshToken };}
Enter fullscreen mode Exit fullscreen mode

Four steps. Let's talk about each one's cost.

Step 1 — User Lookup (Fast)
A SELECT by email via findOneByEmail(). If there's an index on the email column — and there should be — this is a handful of milliseconds at most.

Step 2 — bcrypt.compare() (Slow — on purpose)
This is the one that surprises developers who haven't thought about it before. More on this in a moment.

Step 3 — Create Session (Predictable)
A JWT sign and an INSERT into the user_sessions table. Necessary. The user can't get their tokens without this.

Step 4 — Delete Expired Sessions (Variable, and not the user's problem)
A DELETE WHERE refresh_token_expires_at < NOW(). On a quiet database, this takes a few milliseconds. Under load, with concurrent logins happening, this step starts to hurt — and the user's response time takes the hit.

Why bcrypt Is Slow on Purpose

When you store a password, you store a hash — a one-way transformation. If your database leaks, an attacker can't read passwords directly; they have to crack each hash by brute-force guessing. Fast hashes like MD5 or SHA-256 let a modern GPU try billions of guesses per second. A password cracking session takes minutes.

bcrypt is designed like a lock that's slow to pick — not because it's broken, but because that resistance is the entire point. With a cost factor of 10–12, each guess takes ~50–100ms on a modern CPU. That means an attacker can only try ~10–20 passwords per second per core instead of billions. Brute force becomes impractical.

// bcrypt cost factor — each increment roughly doubles computation time

const hashedPassword = await bcrypt.hash(createUserDto.password, 10);

// At login — same work factor, same ~50–100ms cost 
const isMatch = await bcrypt.compare(pass, user.password);
Enter fullscreen mode Exit fullscreen mode

That ~100ms is unavoidable and intentional. Anything else you add to the login critical path is felt on top of those 100ms. It raises the floor for every user, every login.

The Problem with Cleanup in the Critical Path
Here's the cleanup query that was in the original login flow:

// Ran synchronously inside login() — deleted expired sessions for the logging-in user

await this.userSessionRepository.createQueryBuilder().delete()  .where('refresh_token_expires_at < :now', { now: new Date() })  .andWhere('user_id = :userId', { userId: user.id })  .execute();

Enter fullscreen mode Exit fullscreen mode

On a quiet system: fine. Under load:

  1. Dozens or hundreds of users log in simultaneously
  2. Each login fires a DELETE on user_sessions
  3. The DB handles concurrent deletes alongside concurrent inserts (from Step 3)
  4. Write contention grows — rows lock, queries queue, some start waiting
  5. Login latency becomes unpredictable

Invisible in development, invisible in staging, visible in production when traffic peaks — exactly when you can least afford it.

But there's a more fundamental issue: the user doesn't care about cleanup. When someone logs in, they want their access token. They have zero interest in whether an expired session from three weeks ago gets cleaned up in the same request. That cleanup benefits the system, not the user. Making users wait for your housekeeping is a design smell.

The Fix: Async Cron Cleanup with @nestjs/schedule

Move cleanup out of login() entirely and into a background job.

npm install @nestjs/schedule
Enter fullscreen mode Exit fullscreen mode
// app.module.ts — register the scheduler once 
import { ScheduleModule } from '@nestjs/schedule';

@Module({imports: [   
 ScheduleModule.forRoot(), 
// ...other imports
],})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

A dedicated service whose only job is housekeeping:

// session-cleanup.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserSession } from '../auth/entities/user-session.entity';

@Injectable()
export class SessionCleanupService {  
private readonly logger = new Logger(SessionCleanupService.name);  constructor(
@InjectRepository(UserSession) 
private readonly userSessionRepository: Repository<UserSession>,
) {}  
/*  
* Runs every 30 minutes, fully independent of login traffic.  
* Deletes sessions where the refresh token has expired.   
* Users with active, unexpired sessions are completely unaffected.   */ 

@Cron(CronExpression.EVERY_30_MINUTES) 
async cleanupExpiredSessions(): Promise<void> { 
const result = await this.userSessionRepository      .createQueryBuilder()
.delete()
.where('refresh_token_expires_at < :now', {now: new Date()})     .execute();
this.logger.log(`Session cleanup complete. Removed ${result.affected ?? 0} expired sessions.`);}}

Enter fullscreen mode Exit fullscreen mode

And the lean login method — three steps only:

// auth.service.ts — login critical path only
async login(  email: string,  password: string,  deviceInfo?: { ipAddress?: string; userAgent?: string; deviceType?: string },) 
{  
// Steps 1 + 2: user lookup + bcrypt comparison, via validateUser()  

const user = await this.validateUser(email, password);  
const payload = { email: user.email, sub: user.id, userType: user.userType };  

// Short-lived access token (stateless — no DB lookup on each request)  

const accessToken = this.jwtService.sign(payload, { expiresIn: '60m' }); 

// Long-lived refresh token (stored in DB for revocation support)  const refreshToken = this.jwtService.sign(payload, {secret: this.configService.get<string>('JWT_REFRESH_SECRET'),expiresIn: '7d',  });  

// Step 3: Persist session — only the refresh token, not the access token  
const refreshTokenExpiresAt = new Date();  refreshTokenExpiresAt.setDate(refreshTokenExpiresAt.getDate() + 7);  

await this.createSession(user.id,refreshToken,refreshTokenExpiresAt,    deviceInfo?.ipAddress,deviceInfo?.userAgent,deviceInfo?.deviceType);


// Step 4 is gone. SessionCleanupService runs every 30 min independently.  
return {access_token: accessToken,refresh_token: refreshToken,    expires_in: 3600, user: {id: user.id, email: user.email, firstName: user.firstName, ...},};}
Enter fullscreen mode Exit fullscreen mode

One detail worth noting: only the refresh token is stored in the database. The access token is stateless — validated by JWT signature alone, no DB hit on every authenticated request. This keeps user_sessions lean and the auth hot path fast.

What Big Tech Does

Useful to see where these patterns evolve at greater scale.

Redis + TTL: Many companies move session storage out of the relational DB into Redis. Redis supports per-key TTL — set the expiry at write time, Redis deletes it automatically when it lapses. No cleanup job, no cron, no stale rows.

// Store session with 7-day TTL — Redis handles deletion automatically

await redis.set(  `session:${sessionId}`,  JSON.stringify({ userId, deviceType }),'EX',60 * 60 * 24 * 7,);

Enter fullscreen mode Exit fullscreen mode

Lazy cleanup: Skip proactive cleanup entirely. When a session is read, check if it's expired and delete it then. No job needed; cost spreads across reads.

Async queues (BullMQ, Kafka): At larger scale, bookkeeping gets published to a queue and processed by isolated workers. A future evolution could emit a low-priority cleanup job after login rather than relying on a fixed cron window.

Argon2id: Modern systems are moving from bcrypt to Argon2id — winner of the Password Hashing Competition. Configurable across time and memory cost, more resistant to GPU-based attacks. Same intentional-slowness principle, more modern implementation.

The Principle: Never Make Users Wait for Bookkeeping

The critical path should contain only what the user is waiting for.

Anything that benefits the system — cleanup, analytics, audit logs, notification emails — should be decoupled from the operation the user is performing. This generalises:

  • Don't send a welcome email inside the registration handler — queue it
  • Don't update analytics counters inside the API response — emit an event
  • Don't run cleanup queries inside login — schedule them

Identify what the user is actually waiting for. Make that path lean. Push everything else out of the way.

Tradeoffs

Stale rows linger until the cron runs. A session that expires at 2:00 PM stays in the DB until the 2:30 PM sweep. For most apps this is fine — expired sessions won't pass refreshTokenExpiresAt < new Date() validation. But the rows exist temporarily.

Tune the cron frequency to your traffic. Every 30 minutes is a reasonable default. Millions of DAUs? Consider every 5 minutes. Hundreds of users? Once a day is enough.

Silent failures need alerting. Synchronous cleanup throws — you know when it fails. A cron job that fails silently just doesn't run. The Logger above is the minimum. Add metrics and alerting for production.

Conclusion

This change to an auth service is small but impactful — a new auth service, a cron decorator, a method moved. But the thinking behind it applies to almost any backend system.

Every time you're about to add an operation to a hot path, ask: does the user actually need to wait for this?

If the answer is "no, this benefits the system" — it belongs in background processing. The user should never be the vehicle for your database maintenance.

Follow for more backend engineering deep dives.

Top comments (0)