DEV Community

derekzyl
derekzyl

Posted on

# How I Fixed Our Auth Bottleneck: From Some Seconds to Instant

Last week, our cart operations were taking 18+ seconds. Not minutes—seconds. Each time a user added something to their cart, they'd wait nearly 20 seconds staring at a loading spinner. Meanwhile, our public pages? Lightning fast.

Something was very, very wrong.

Finding the Problem

The weird part was the inconsistency. Public endpoints responded in milliseconds. But anything requiring authentication? Painful. I pulled up the network inspector and there it was—18.72 seconds spent "waiting for server response."

Not network latency. Not a slow database query. The auth middleware itself was the bottleneck.

Here's the thing—I'd already spent weeks optimizing every route. Added indexes, optimized queries, used .lean() for read operations, implemented proper pagination. The cart service? Optimized. The wishlist endpoints? Optimized. Product queries? All good.

But none of that mattered because the auth middleware was running before every single route, killing performance before my optimized code even got a chance to run.

Here's what was happening on every single authenticated request:

const verifyCallback = async (err, user, info) => {
  // ... auth checks ...

  const roles = await ROLES.find();  // Hit the database
  const mapRoles = {};

  for (const role of roles) {
    // Another database hit for EACH role
    const permissions = await convertPermissionIdsToNames(role.permissions);
    mapRoles[role.name] = permissions;
  }

  // ... permission checking ...
};
Enter fullscreen mode Exit fullscreen mode

Every. Single. Request.

For a system where roles and permissions rarely change (maybe once a month?), we were hammering the database constantly. Classic N+1 query problem, running on every auth check.

The Fix: Three Layers of Caching

I needed speed without breaking things. The solution was a three-tier cache:

Tier 1: Local Memory (~0.1ms)

Keep permissions in memory for 10 minutes. Fastest possible access.

Tier 2: Redis (~1-5ms)

Shared across all server instances, lasts 24 hours. Fast enough and handles scaling.

Tier 3: Database (~100-500ms)

Only when both caches miss, which is rare after the first request.

Here's how it flows:

const buildRolePermissionsCache = async () => {
  // Try local memory first
  let permissions = getRolePermissionsFromLocalCache();
  if (permissions) return permissions;

  // Try Redis next
  permissions = await getRolePermissionsFromRedis();
  if (permissions) {
    setRolePermissionsToLocalCache(permissions);
    return permissions;
  }

  // Fine, hit the database
  permissions = await buildRolePermissionsFromDatabase();

  // Cache it everywhere for next time
  await setRolePermissionsToRedis(permissions);
  setRolePermissionsToLocalCache(permissions);

  return permissions;
};
Enter fullscreen mode Exit fullscreen mode

The local cache implementation is dead simple:

let localRolePermissionsCache = null;
let localCacheTimestamp = 0;
const LOCAL_CACHE_DURATION = 10 * 60 * 1000; // 10 minutes

const getRolePermissionsFromLocalCache = () => {
  const now = Date.now();

  if (localRolePermissionsCache && 
      (now - localCacheTimestamp) < LOCAL_CACHE_DURATION) {
    return localRolePermissionsCache;
  }

  return null;
};
Enter fullscreen mode Exit fullscreen mode

Nothing fancy. Just a variable, a timestamp, and a TTL check.

Fixing the Database Queries Too

While I was in there, I also optimized how we fetch from the database when we do need to:

// Before: Sequential queries
const roles = await ROLES.find();
for (const role of roles) {
  const permissions = await getPermissions(role.permissions);
  // ...
}

// After: Parallel queries with a lookup map
const [roles, permissions] = await Promise.all([
  ROLES.find().lean(),
  PERMISSIONS.find().lean()
]);

const permissionMap = new Map();
permissions.forEach(perm => {
  permissionMap.set(perm._id.toString(), perm.name);
});

// Now O(1) lookups instead of O(n) database queries
Enter fullscreen mode Exit fullscreen mode

The .lean() calls help too—they skip Mongoose's document hydration, which we don't need for read-only operations.

Other Wins Along the Way

While debugging, I found another issue: we were calculating shipping fees on every cart operation. That meant address lookups, shipping API calls, all for something the user doesn't see until checkout.

Quick fix:

// Before
const defaultAddress = await addressService.getUserDefaultAddress(userId);
if (defaultAddress) {
  shippingFee = await shippingService.getShippingFee(defaultAddress.id);
}

// After
shippingFee = 0; // Calculate at checkout instead
Enter fullscreen mode Exit fullscreen mode

Saved another few hundred milliseconds per request.

I also added some database indexes that were missing:

cartItemSchema.index({ cartId: 1, productId: 1 }, { unique: true });
cartSchema.index({ userId: 1 });
wishlistSchema.index({ userId: 1 }, { unique: true });
Enter fullscreen mode Exit fullscreen mode

These should have been there from day one, honestly.

The Results

First authenticated request: 100-500ms (cold cache, hits database)

Every request after that: ~0.1ms (local cache hit)

Cart operations went from 18 seconds to under half a second. Wishlist operations saw similar improvements. The entire app feels snappier.

Best part? The cache stays warm across requests, so most users never hit the slow path. And when roles/permissions do change (rarely), I just call:

await clearRolePermissionsCache();
Enter fullscreen mode Exit fullscreen mode

Both caches get wiped, next request rebuilds them, life goes on.

What I Learned

Profile before optimizing. I almost started optimizing database queries before realizing auth was the real problem. The network inspector saved me days of wasted effort.

Cache aggressively when data is stable. Roles and permissions change maybe once a month. There's no reason to query them thousands of times per hour.

Multiple cache tiers aren't overkill. Local memory for speed, Redis for scaling, database as fallback. Each serves a purpose.

Don't over-engineer early. I added monitoring and stats later, once the basic caching worked. Getting it working first, then making it observable, was the right order.

Indices matter. Seriously, if you're doing Model.find({ userId }) frequently, just add the damn index.

The Code

Everything's in production now. The local cache is stupid simple—just a variable and a timestamp. Redis handles the cross-instance sharing. The database is the fallback that rarely gets hit.

If you're dealing with similar auth slowdowns, the pattern is pretty straightforward: identify what rarely changes, cache it aggressively, and only invalidate when it actually changes.

And always, always profile first. I wasted an afternoon optimizing database queries that weren't the problem. The network inspector pointed straight at auth middleware within five minutes.


P.S. If you're warming up the cache on server startup, do it after MongoDB connects but before accepting requests. Learned that one the hard way.

mongoose.connect(mongoUrl).then(async () => {
  await warmUpAuthCache(); // Do this first
  server.listen(port);      // Then start accepting traffic
});
Enter fullscreen mode Exit fullscreen mode

Top comments (0)