DEV Community

Cover image for Why You Should Cache More and Query Less
Gabriel Ávila
Gabriel Ávila

Posted on

Why You Should Cache More and Query Less

Still hitting the same API again and again?

It’s not just inefficient — it’s expensive. Slow performance, higher costs, and a worse experience for your users.

Let’s put a stop to that — starting with this article. :D

First you need to know Redis, long story short, it is an ultra-fast, in-memory key-value store — perfect for caching and reducing load on your backend.

There are some ways you can start.

If you are developing, I recommend to use with Docker.

To do so you can run on your CLI:

docker run -d --name redis-stack-server -p 6379:6379 redis/redis-stack-server:latest
Enter fullscreen mode Exit fullscreen mode

That will start the Redis server. But seeing what’s actually stored is just as important. For that, use the full Redis Stack with a browser UI:

docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latest
Enter fullscreen mode Exit fullscreen mode

Now, if you go to your http://localhost:8001/redis-stack/browser

You'll find a handy dashboard where you can store, browse, and manage your Redis data visually:

Image description

To get started, install the redis package:

ppm i redis // or yarn, npm
Enter fullscreen mode Exit fullscreen mode

Now, let’s set up a few types and interfaces to keep things clean and scalable.

We’ll start with a generic enum for standardizing responses:

Our enum looks like:

# generic.interface.ts
export enum StatusEnum {
  SUCCESS = 'success',
  ERROR = 'error'
}
Enter fullscreen mode Exit fullscreen mode

Then, define the types and structure for our caching logic:

# cache.interface.ts
import { StatusEnum } from './generic.interface';

export type CacheCallbackType = Promise<{
  status: StatusEnum;
  data: any;
  error?: any;
}>;

export interface CacheInterface {
  key: string;
  callback: () => CacheCallbackType;
  configs?: {
    expirationInSeconds?: number;
    useUserId?: boolean;
  };
}

interface CacheReturnInterface {
  status: StatusEnum;
  data: any;
}

export type CacheReturnType = Promise<CacheReturnInterface>;
Enter fullscreen mode Exit fullscreen mode

These interfaces will help keep our code typed, consistent, and easy to maintain as the project grows.

Next, let’s set up a helper class to handle Redis operations in a clean and reusable way.

import { createClient } from 'redis';

export default class RedisHelper {
  private static client = createClient({
    username: 'default',
    password: process.env.REDIS_PASSWORD,
    socket: {
      host: process.env.REDIS_URL,
      port: parseInt(process.env.REDIS_PORT)
    }
  });

  static async connect() {
    if (!this.client.isOpen) {
      await this.client.connect();
    }

    return this;
  }

  static async get(key: string) {
    await this.connect();
    const value = await this.client.get(key);

    if (!value) {
      return value;
    }

    return value;
  }

  static async set(
    key: string,
    value: any,
    expirationInSeconds: number = 3600
  ) {
    await this.connect();
    await this.client.json.set(key, '$', value);
    await this.client.expire(key, expirationInSeconds);

    return this;
  }

  static async del(key: string) {
    await this.connect();
    await this.client.del(key);
  }

  static async quit() {
    if (this.client.isOpen) {
      await this.client.quit();
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

We're using Redis' JSON commands (e.g., .json.set) to store structured data more intuitively. This gives us more flexibility than plain strings—especially when dealing with nested objects or arrays.

With this helper, we can:

  • Connect to Redis only when needed (lazy connection)
  • Set values with expiration
  • Get cached data easily
  • Delete keys
  • Gracefully close the connection with quit

This helper will handle caching for any kind of request—as long as the request is successful. It uses the RedisHelper we created to communicate with Redis.

In my case, I’m including support for user-based caching, by appending the user ID to the cache key. In your case, feel free to adapt this logic to suit your needs.

import {
  CacheInterface,
  CacheReturnType
} from '../../interfaces/cache.interface';
import { StatusEnum } from '../../interfaces/generic.interface';
// import getUserId  from '../default/userId';
import RedisHelper from './RedisHelper';

function getUserId () {
   return Math.random(); // use your logic here!
}

export default class CacheHelper {
  // checks if the cache is empty
  // then runs the callback function
  // and sets the cache with the result
  static async checkCache({
    key,
    callback,
    configs
  }: CacheInterface): CacheReturnType {
    const cache = await RedisHelper.get(key);
    const expirationInSeconds = configs?.expirationInSeconds || 3600;

    // only use userId in the key if specified in configs
    if (configs?.useUserId) {
      const userId = getUserId();
      key = `${key}:${userId}`;
    }

    if (cache) {
      return {
        status: StatusEnum.SUCCESS,
        data: cache
      };
    }

    try {
      const result = await callback();

      if (result.status === StatusEnum.ERROR) {
        return {
          status: StatusEnum.ERROR,
          data: result.data
        };
      }

      await RedisHelper.set(key, result.data, expirationInSeconds);

      return {
        status: StatusEnum.SUCCESS,
        data: result.data
      };
    } catch (error) {
      console.error('Error in checkCache:', error);

      return {
        status: StatusEnum.ERROR,
        data: null
      };
    }
  }

  // helper to always use userId in the key
  static async checkCacheWithId({
    key,
    callback,
    configs
  }: CacheInterface): CacheReturnType {
    return this.checkCache({
      key,
      callback,
      configs: {
        ...configs,
        useUserId: true
      }
    });
  }

  static async deleteCache(
    key: string,
    configs?: {
      useUserId?: boolean;
    }
  ): Promise<void> {
    if (configs?.useUserId) {
      const userId = getUserId();
      key = `${key}:${userId}`;
    }

    if (!key) {
      throw new Error('Key is required to delete cache');
    }

    try {
      await RedisHelper.del(key);
    } catch (error) {
      console.error('Error deleting cache:', error);
      throw error;
    }
  }

  static async deleteUserCache(key: string): Promise<void> {
    return this.deleteCache(key, { useUserId: true });
  }
}

Enter fullscreen mode Exit fullscreen mode

This utility gives you full control over:

  • Reading from the cache
  • Writing to it if needed
  • Handling cache keys with or without user identifiers
  • Deleting cached entries easily

Oh, a tag in redis looks like this:

movies:user_123
user:123:profile
user:123:posts
user:123:notifications
Enter fullscreen mode Exit fullscreen mode

Now that everything is set up, using the cache is simple and clean.

Here’s an example of how to cache a /categories API request based on user ID:

import CacheHelper from './helpers/cache/CacheHelper';
import { StatusEnum } from './interfaces/generic.interface';

api.post('/categories', async (_req, res) => {
  const data = await CacheHelper.checkCacheWithId({
    key: 'categories',
    callback: async (): CacheCallbackType => {
      const data = await Categories();

      if (data.error) {
        return {
          status: StatusEnum.ERROR,
          data: data.error
        };
      }

      return {
        status: StatusEnum.SUCCESS,
        data
      };
    }
  });

  if (data.status === StatusEnum.ERROR) {
    return res.status(500).send({ error: data.data });
  }

  return res.status(200).send(data.data);
});

Enter fullscreen mode Exit fullscreen mode

✅ What’s happening here?

  • checkCacheWithId checks if there's already a cached version of the response for that specific user.
  • If there's no cache, it runs the callback (Categories()), saves the result, and returns it.
  • If there’s an error in the callback, it won’t be cached—and the error is returned instead.
  • The result is returned to the client, fast and clean.

Want to go a step further? You could abstract this into a middleware or use decorators (if you're working with a framework like NestJS).

Let me know if you'd like help with that next!

Top comments (0)