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