Batching Redis lookups with DataLoader and MGET

When I first discovered DataLoader (2017) and how it works in the context of GraphQL, I was blown away. It felt like magic. However, I have not really found more use cases for it since then. Until now.

If you have a Node.js application that uses Redis for caching, chances are that you have something that looks like this:

const cache = async <T>(options: {
  get: () => Promise<T>;
  key: string;
  ttl: number;
}): Promise<T> => {
  const { get, key, ttl } = options;
 
  const cached = await redis.get(key);
 
  if (cached) {
    return JSON.parse(cached);
  }
 
  const value = await get();
 
  await redis.set(key, JSON.stringify(value), { EX: ttl });
 
  return value;
};

This works fine. However, it is not efficient because it makes a separate network roundtrip for each cache lookup.

Consider a GraphQL resolver that resolves a list of users, each of which needs a cached profile:

const resolvers = {
  Query: {
    team: async (_, { teamId }) => {
      const memberIds = await db.getTeamMemberIds(teamId);
 
      return Promise.all(
        memberIds.map((id) =>
          cache({
            get: () => db.fetchUser(id),
            key: `user:${id}`,
            ttl: 60,
          }),
        ),
      );
    },
  },
};

If the team has 30 members, this fires 30 individual GET commands to Redis – 30 separate network roundtrips. Each one is fast on its own, but the cumulative latency of 30 separate roundtrips adds up.

This is the same N+1 problem that DataLoader solves for database queries – except it is happening at the cache layer.

Redis has an MGET command that fetches multiple keys in a single roundtrip. DataLoader can collect all the individual load() calls that happen within the same microtick and batch them into one MGET:

const redisLoader = new DataLoader<string, string | null>(
  async (keys) => {
    return redis.mGet([...keys]);
  },
  {
    batchScheduleFn: (callback) => {
      // You could even nest `queueMicrotask` calls to widen the batching window.
      queueMicrotask(callback);
    },
    // We disable DataLoader's built-in memoization cache because we are using DataLoader purely
    // as a batching mechanism. Leaving it enabled would cause stale reads within the same request.
    cache: false,
  },
);
 
const cache = async <T>(options: {
  get: () => Promise<T>;
  key: string;
  ttl: number;
}): Promise<T> => {
  const { get, key, ttl } = options;
 
  const cached = await redisLoader.load(key);
 
  if (cached) {
    return JSON.parse(cached);
  }
 
  const value = await get();
 
  await redis.set(key, JSON.stringify(value), { EX: ttl });
 
  return value;
};

The API is identical – callers do not need to change anything. But now those 30 cache() calls from the resolver example above result in a single MGET command with 30 keys instead of 30 separate GET commands.

The key insight is how DataLoader's batching interacts with the Node.js event loop:

  1. Multiple cache() calls are initiated in the same tick (e.g. via Promise.all).
  2. Each call invokes redisLoader.load(key), which queues the key internally.
  3. At the end of the microtick, DataLoader invokes the batch function with all queued keys.
  4. The batch function calls redis.mGet() with all keys at once – one roundtrip.
  5. DataLoader distributes the results back to each individual load() caller.

Some Redis clients (like ioredis) support auto-pipelining, which buffers whatever commands you issue within the same event loop tick and flushes them over the wire together. This reduces the number of network roundtrips, but you are still executing separate commands – Redis parses, evaluates, and responds to each GET individually.

With MGET, Redis handles the batch as a single atomic operation internally – one parse, one lookup loop, one response array.

However, the two can coexist.

Here is what a single request looks like in Redis MONITOR before and after the change.

Before – 24 individual GET commands:

1773343380.941218 [0 10.12.3.170:43010] "GET" "UserProfile.organization:35019388"
1773343380.941455 [0 10.12.3.170:43010] "GET" "UserProfile.organization:35589195"
1773343380.941694 [0 10.12.3.170:43010] "GET" "UserProfile.organization:34987467"
1773343380.941882 [0 10.12.3.170:43010] "GET" "UserProfile.organization:35768999"
1773343380.942076 [0 10.12.3.170:43010] "GET" "UserProfile.organization:236884"
1773343380.942251 [0 10.12.3.170:43010] "GET" "UserProfile.organization:35511356"
1773343380.942439 [0 10.12.3.170:43010] "GET" "UserProfile.organization:35644146"
1773343380.942618 [0 10.12.3.170:43010] "GET" "UserProfile.organization:35747577"
1773343380.942802 [0 10.12.3.170:43010] "GET" "UserProfile.organization:35400294"
1773343380.942985 [0 10.12.3.170:43010] "GET" "UserProfile.organization:35706197"
1773343380.943164 [0 10.12.3.170:43010] "GET" "UserProfile.organization:35214285"
1773343380.943348 [0 10.12.3.170:43010] "GET" "UserProfile.organization:35055175"
1773343380.943529 [0 10.12.3.170:43010] "GET" "UserProfile.organization:34841794"
1773343380.943714 [0 10.12.3.170:43010] "GET" "UserProfile.organization:277227"
1773343380.943895 [0 10.12.3.170:43010] "GET" "UserProfile.organization:35747531"
1773343380.944078 [0 10.12.3.170:43010] "GET" "UserProfile.organization:34895461"
1773343380.944260 [0 10.12.3.170:43010] "GET" "UserProfile.organization:35543971"
1773343380.944443 [0 10.12.3.170:43010] "GET" "UserProfile.organization:36294031"
1773343380.944621 [0 10.12.3.170:43010] "GET" "UserProfile.organization:35922938"
1773343380.944807 [0 10.12.3.170:43010] "GET" "UserProfile.organization:5460056"
1773343380.944988 [0 10.12.3.170:43010] "GET" "UserProfile.organization:35777176"
1773343380.945169 [0 10.12.3.170:43010] "GET" "UserProfile.organization:35869830"
1773343380.945351 [0 10.12.3.170:43010] "GET" "UserProfile.organization:35643962"
1773343380.945534 [0 10.12.3.170:43010] "GET" "UserProfile.organization:35601727"

After – a single MGET command:

1773343380.951886 [0 10.12.3.170:43010] "MGET" "UserProfile.organization:35019388" "UserProfile.organization:35589195" "UserProfile.organization:34987467" "UserProfile.organization:35768999" "UserProfile.organization:236884" "UserProfile.organization:35511356" "UserProfile.organization:35644146" "UserProfile.organization:35747577" "UserProfile.organization:35400294" "UserProfile.organization:35706197" "UserProfile.organization:35214285" "UserProfile.organization:35055175" "UserProfile.organization:34841794" "UserProfile.organization:277227" "UserProfile.organization:35747531" "UserProfile.organization:34895461" "UserProfile.organization:35543971" "UserProfile.organization:36294031" "UserProfile.organization:35922938" "UserProfile.organization:5460056" "UserProfile.organization:35777176" "UserProfile.organization:35869830" "UserProfile.organization:35643962" "UserProfile.organization:35601727"

In our production system, this change reduced the number of Redis commands per request by an order of magnitude for cache-heavy paths. The latency improvement scales with the number of concurrent cache lookups – the more you batch, the more you save.

It's a small change that can have a big impact on performance.