Phase 5 - Caching | Post 9 | Caching — Making Your Services Fast With Zero Extra Effort

Post 9 of 15 | Phase 5: Caching


Caching — Making Your Services Fast With Zero Extra Effort

In the previous post you learned how to keep your application alive when things break. In this post you learn how to make your application fast. Caching is one of the biggest performance improvements you can add to any system, and Moleculer makes it extremely simple.


What is Caching and Why Do You Need It

Every time an action is called, it executes its handler. If that handler reads from a database, it makes a database query. If 1000 requests per second call user.getById with the same user ID, you are making 1000 database queries per second for the exact same data that never changed.

Caching solves this by storing the result of an action call. The next time the same action is called with the same parameters, Moleculer returns the stored result immediately without executing the handler or touching the database.

Think of it like a waiter who memorizes your regular order. The first time you come in, he goes to the kitchen and gets your food. Every time after that, he already knows what you want and brings it immediately without asking the kitchen.


How Moleculer Caching Works

Moleculer caching works at the action level. You mark an action as cacheable. Moleculer automatically generates a cache key based on the action name and the params. If that key exists in the cache, it returns the cached result. If not, it runs the handler, stores the result, and returns it.

First call: user.getById { id: "1" }
→ Cache miss
→ Handler runs, hits database
→ Result stored in cache with key "user.getById:1"
→ Result returned to caller

Second call: user.getById { id: "1" }
→ Cache hit
→ Handler does NOT run
→ Cached result returned immediately
→ Database not touched

This is completely transparent to the caller. They call the same action the same way. They get the same result. They just get it much faster.


Available Cache Adapters

Moleculer supports multiple caching backends:

  • Memory — stores cache in the Node.js process memory. Fastest possible. Lost on restart. Not shared between nodes.
  • MemoryLRU — same as Memory but with LRU eviction. Automatically removes least recently used items when memory fills up.
  • Redis — stores cache in Redis. Persists across restarts. Shared between all nodes. Recommended for production.

Enabling the Cache Adapter

In moleculer.config.js:

Memory cache:

module.exports = {
    cacher: "Memory"
};

MemoryLRU cache with max size:

module.exports = {
    cacher: {
        type: "MemoryLRU",
        options: {
            max: 1000,    // Maximum 1000 items in cache
            ttl: 30       // Default TTL 30 seconds
        }
    }
};

Redis cache:

module.exports = {
    cacher: {
        type: "Redis",
        options: {
            host: "localhost",
            port: 6379,
            password: "",
            db: 0,
            ttl: 30       // Default TTL 30 seconds
        }
    }
};

Install the Redis client if using Redis cache:

npm install ioredis

Making an Action Cacheable

Once you have a cache adapter configured, marking an action as cacheable is one line:

module.exports = {
    name: "user",

    actions: {
        getById: {
            // Add this one line — that is it
            cache: true,

            params: {
                id: "string"
            },
            async handler(ctx) {
                this.logger.info(`Hitting database for user ${ctx.params.id}`);
                // Simulate database call
                return { id: ctx.params.id, name: "Rahul Sharma" };
            }
        }
    }
};

Test this in REPL:

call user.getById {"id": "1"}
call user.getById {"id": "1"}
call user.getById {"id": "1"}

In your terminal you will see the logger message "Hitting database for user 1" only once. The second and third calls returned the cached result without running the handler.


Cache Keys — How Moleculer Generates Them

By default when you set cache: true, Moleculer generates a cache key using all the params. So:

user.getById { id: "1" }       → cache key: "user.getById:1"
user.getById { id: "2" }       → cache key: "user.getById:2"
user.list { page: 1, limit: 10 } → cache key: "user.list:1|10"

Each unique combination of params gets its own cache entry. This is correct behavior in most cases.

Custom cache keys:

Sometimes you want to control which params are used in the cache key. For example, if your action receives a large params object but only the id field determines the result, you only want the key based on id.

actions: {
    getById: {
        cache: {
            // Only use "id" field for the cache key
            // Other params are ignored for caching purposes
            keys: ["id"]
        },
        params: {
            id: "string"
        },
        handler(ctx) {
            return { id: ctx.params.id, name: "Rahul Sharma" };
        }
    }
}

Including ctx.meta in the cache key:

Sometimes the cache result depends on something in ctx.meta, like the current user's role or language preference. You can include meta fields in the key:

actions: {
    getProfile: {
        cache: {
            // Include both params and a meta field in the key
            keys: ["id", "#user.role"]
            // # prefix means it comes from ctx.meta, not ctx.params
        },
        handler(ctx) {
            const role = ctx.meta.user.role;
            // Admin gets full profile, user gets limited profile
            if (role === "admin") {
                return { id: ctx.params.id, name: "Rahul", salary: 50000 };
            }
            return { id: ctx.params.id, name: "Rahul" };
        }
    }
}

TTL — Time to Live

TTL defines how long a cached result stays valid. After the TTL expires, the next call runs the handler again and refreshes the cache.

Global TTL in moleculer.config.js:

module.exports = {
    cacher: {
        type: "Memory",
        options: {
            ttl: 30   // All cached items expire after 30 seconds by default
        }
    }
};

Per-action TTL override:

actions: {
    // User data changes rarely — cache for 5 minutes
    getById: {
        cache: {
            ttl: 300
        },
        handler(ctx) {
            return getUserFromDB(ctx.params.id);
        }
    },

    // Exchange rates change every minute — cache for 60 seconds
    getExchangeRate: {
        cache: {
            ttl: 60
        },
        handler(ctx) {
            return fetchExchangeRateFromAPI(ctx.params.currency);
        }
    },

    // Real-time stock price — do not cache at all
    getStockPrice: {
        // No cache property — handler runs every time
        handler(ctx) {
            return fetchLiveStockPrice(ctx.params.symbol);
        }
    }
}

Choose TTL based on how often data changes and how much staleness is acceptable for your use case.


Cache Invalidation — The Hard Part

Caching is easy. Knowing when to clear the cache is the hard part. When data changes, the cached version becomes stale. You need to clear it so the next call gets fresh data.

Moleculer gives you two ways to clear cache.

Method 1: Clear specific cache entries

module.exports = {
    name: "user",

    actions: {
        // READ — cacheable
        getById: {
            cache: { keys: ["id"], ttl: 300 },
            handler(ctx) {
                return getUserFromDB(ctx.params.id);
            }
        },

        // WRITE — clears the cache after updating
        update: {
            params: {
                id: "string",
                name: "string"
            },
            async handler(ctx) {
                // Update in database
                const updated = await updateUserInDB(ctx.params.id, ctx.params.name);

                // Clear the specific cache entry for this user
                // This uses the same key format as the getById cache
                await this.broker.cacher.del(`user.getById:${ctx.params.id}`);

                this.logger.info(`Cache cleared for user ${ctx.params.id}`);

                return updated;
            }
        }
    }
};

Method 2: Clear cache using built-in cache cleaning event

Moleculer has a cleaner approach using the built-in cache.clean event. Instead of manually building cache key strings, you broadcast a clean event with a pattern:

module.exports = {
    name: "user",

    actions: {
        getById: {
            cache: { keys: ["id"], ttl: 300 },
            handler(ctx) {
                return getUserFromDB(ctx.params.id);
            }
        },

        list: {
            cache: { ttl: 60 },
            handler(ctx) {
                return getAllUsersFromDB();
            }
        },

        update: {
            params: {
                id: "string",
                name: "string"
            },
            async handler(ctx) {
                const updated = await updateUserInDB(ctx.params.id, ctx.params.name);

                // Clear ALL cache entries for the user service
                // The pattern "user.**" matches every cache key that starts with "user."
                await this.broker.broadcast("cache.clean.user");

                return updated;
            }
        },

        create: {
            async handler(ctx) {
                const newUser = await createUserInDB(ctx.params);

                // Clear the user list cache since a new user was added
                await this.broker.broadcast("cache.clean.user");

                return newUser;
            }
        }
    },

    events: {
        // Listen for the cache clean event and clear matching entries
        "cache.clean.user"() {
            if (this.broker.cacher) {
                this.broker.cacher.clean("user.**");
            }
        }
    }
};

The broadcast approach is better than manual key deletion because:

  • It works across all nodes when using Redis cache with a transporter
  • You do not need to manually construct cache key strings
  • One event clears cache on every node simultaneously

Practical Example — Full Service With Caching

Let us build a complete product service with proper caching and cache invalidation. Create services/product.service.js:

"use strict";

// Simulated database
const products = [
    { id: "1", name: "Mechanical Keyboard", price: 2999, stock: 50 },
    { id: "2", name: "Wireless Mouse", price: 1499, stock: 100 },
    { id: "3", name: "USB Hub", price: 799, stock: 200 }
];

let nextId = 4;

module.exports = {
    name: "product",

    actions: {
        // List all products — cache for 60 seconds
        list: {
            rest: { method: "GET", path: "/" },
            cache: {
                ttl: 60
            },
            handler(ctx) {
                this.logger.info("Fetching product list from database");
                return products;
            }
        },

        // Get one product — cache for 5 minutes
        getById: {
            rest: { method: "GET", path: "/:id" },
            cache: {
                keys: ["id"],
                ttl: 300
            },
            params: {
                id: "string"
            },
            handler(ctx) {
                this.logger.info(`Fetching product ${ctx.params.id} from database`);
                const product = products.find(p => p.id === ctx.params.id);
                if (!product) {
                    throw new Error("Product not found");
                }
                return product;
            }
        },

        // Create product — invalidate list cache
        create: {
            rest: { method: "POST", path: "/" },
            params: {
                name: "string",
                price: "number",
                stock: "number"
            },
            async handler(ctx) {
                const newProduct = {
                    id: String(nextId++),
                    ...ctx.params
                };
                products.push(newProduct);

                // List changed — clear list cache
                await this.broker.broadcast("cache.clean.product");
                this.logger.info("Product created and cache cleared");

                return newProduct;
            }
        },

        // Update product — invalidate specific product cache and list cache
        update: {
            rest: { method: "PUT", path: "/:id" },
            params: {
                id: "string",
                name: { type: "string", optional: true },
                price: { type: "number", optional: true },
                stock: { type: "number", optional: true }
            },
            async handler(ctx) {
                const index = products.findIndex(p => p.id === ctx.params.id);
                if (index === -1) {
                    throw new Error("Product not found");
                }

                products[index] = { ...products[index], ...ctx.params };

                // Clear all product cache — both list and individual entries
                await this.broker.broadcast("cache.clean.product");
                this.logger.info(`Product ${ctx.params.id} updated and cache cleared`);

                return products[index];
            }
        }
    },

    events: {
        "cache.clean.product"() {
            if (this.broker.cacher) {
                // Clear all cache entries starting with "product."
                this.broker.cacher.clean("product.**");
                this.logger.info("Product cache cleared");
            }
        }
    }
};

Enable caching in moleculer.config.js:

module.exports = {
    cacher: {
        type: "Memory",
        options: {
            ttl: 30
        }
    }
};

Now test with these requests in sequence:

GET http://localhost:3000/api/product

Check terminal — you see "Fetching product list from database".

GET http://localhost:3000/api/product

Check terminal — nothing printed. Cache was returned.

POST http://localhost:3000/api/product
Body: { "name": "Monitor", "price": 15000, "stock": 20 }

Check terminal — "Product created and cache cleared".

GET http://localhost:3000/api/product

Check terminal — "Fetching product list from database" again. Cache was cleared and fresh data was fetched.


Checking Cache Status in REPL

The REPL has built-in commands for inspecting the cache:

npm run repl

List all cached keys:

cache keys

Get a specific cached value:

cache get product.getById:1

Delete a specific cache key:

cache del product.getById:1

Clear all cache:

cache clear

These are extremely useful during development when you want to inspect or manually clear cache without restarting the server.


Redis Cache for Production

In production with multiple nodes, Memory cache does not work properly. Each node has its own memory cache. When Node A updates a product and clears its cache, Node B still has the old cached value in its own memory.

Redis solves this because it is a shared external cache. All nodes read from and write to the same Redis instance. When any node clears a cache entry, it is cleared for everyone.

Switch to Redis cache in production simply by changing moleculer.config.js:

module.exports = {
    cacher: {
        type: "Redis",
        options: {
            host: "localhost",
            port: 6379,
            ttl: 30
        }
    }
};

Your service code does not change at all. The cache.clean event and this.broker.cacher.clean() work exactly the same way with Redis as they do with Memory.


What to Cache and What Not to Cache

Cache these:

  • Data that is read frequently but changes rarely — user profiles, product details, categories
  • Results of expensive database queries — aggregations, joins, reports
  • Results of external API calls — exchange rates, weather data, shipping rates

Do not cache these:

  • Real-time data — live stock prices, active user counts
  • User-specific sensitive data — unless you include user ID in the cache key
  • Write operations — create, update, delete actions should never be cached
  • Data that must always be fresh — inventory levels during checkout, payment status

Summary

  • Caching stores action results and returns them on subsequent identical calls without running the handler.
  • Three cache adapters: Memory (fastest, not shared), MemoryLRU (auto eviction), Redis (shared across nodes, recommended for production).
  • Enable caching on an action with cache: true or cache: { keys: [], ttl: 0 }.
  • Cache keys are generated from action name and params by default.
  • Use keys array to control which params are included in the cache key.
  • Use # prefix in keys array to include ctx.meta fields in the cache key.
  • TTL controls how long cached data stays valid. Set per-action based on how often data changes.
  • Cache invalidation on write: broadcast a clean event and listen to it in the service.
  • Use this.broker.cacher.clean("service.**") to clear all cache entries for a service.
  • Redis cache is mandatory in production with multiple nodes. Memory cache is per-node.
  • REPL commands: cache keys, cache get, cache del, cache clear.

Up Next

Post 10 covers the API Gateway — moleculer-web. This is how you expose your internal Moleculer services over HTTP so browsers, mobile apps, and external clients can reach them. We will cover routing, authentication hooks, request mapping, file uploads, and CORS configuration.


Course Progress: 9 of 15 posts complete.

No comments:

Post a Comment

Phase 5 - Caching | Post 9 | Caching — Making Your Services Fast With Zero Extra Effort

Post 9 of 15 | Phase 5: Caching Caching — Making Your Services Fast With Zero Extra Effort In the previous post you learned how to keep your...