Introduction
In today’s world of cloud-based applications and services, performance is key. As developers, we are always looking for ways to reduce latency and enhance the user experience. This is especially true for the SAP Cloud Application Programming Model (CAP) — the go-to framework in the SAP BTP ecosystem — which, when combined with SAP HANA Cloud, delivers excellent performance for most use cases. However, outside of simple Fiori Elementsapp use cases, I’ve personally struggled with remote service latency and runtime-heavy calculations that slowed things down significantly. Making users wait 40+ seconds for a response was simply not an option. Of course, the first principle should be to tackle the core problem, however, I had no control over fixing the response times of external services from third-party providers. So, what to do in such scenarios?
One proven way to overcome these bottlenecks is caching — a technique that temporarily stores frequently or slow-access data for faster retrieval, reducing both latency and system load. While caching can be implemented at various levels (e.g., browser, database), this post focuses on application-level caching within a CAP application.
To address my own performance needs, I built cds-caching —an open-source CAP plugin that seamlessly integrates with CAP, providing an easy-to-use yet powerful caching service. The best part? It’s open-source, available as an NPM module, and ready for you to use in your own CAP applications./p>
This blog post contains a deep-dive into cds-caching, including a description of its features with many code examples and some guidance on how to use it. If this grabs your attention, you are dealing with performance issues yourself and/or you currently have to wait for other slow service responses, I would be happy if you follow me along.
Caching vs. Replication
Before diving in, it’s important to clarify the difference between caching and data replication, as both enhance data access but serve distinct purposes:
- Caching temporarily stores data to reduce latency and improve response times. It’s ideal for read-heavy workloads, such as caching the results of expensive queries, calculations, or external API calls. However, caching does not maintain data integrity and is unaware of data semantics.
- Replication creates full, persistent copies of remote data within your application to ensure availability and enable seamless data sharing across systems. It focuses on resilience rather than fine-tuned performance optimization for specific queries.
Understanding this distinction is crucial for selecting the right approach. cds-caching is designed for efficient caching, not replication. And as for replication in CAP? Well, that might be a topic for another blog post—or even another CAP plugin in the future! 😉
Introducing cds-caching
cds-caching is a CAP plugin that integrates seamlessly into any CAP application with a requirement for caching capabilities. It provides a CALESI-pattern compliant cds.Service, abstracting access to caching backends (preferably Redis) while offering a flexible and developer-friendly API. Built from real-world needs, cds-caching is open-source—so feel free to check it out on GitHub!
Features
cds-caching is based on the widely used Keyv library, but adds some CAP-specific flavor on top: 🚀
- Flexible Key-Value Store – Store and retrieve data using simple key-based access.
- Event Handling – Monitor and react to cache events, such as before/after storage and retrieval.
- CAP-specific Caching – Effortlessly cache CQN queries or CAP cds.Requests using code or the @cache annotation.
- TTL Support – Automatically manage data expiration with configurable time-to-live (TTL) settings.
- Tag Support – Use dynamic tags for flexible cache invalidation options.
- Pluggable Storage Options – Choose between in-memory caching or Redis.
- Compression – Compress cached data to save memory.
- Integrated Statistics (Work in progress) – Integrated statistics on cache hits, etc.
Installation
npm install cds-caching
Next, add a caching service configuration to your package.json. You can even define multiple caching services, which is recommended if you need to cache different types of data within your application.
{
"cds": {
"requires": {
"caching": {
"impl": "cds-caching",
"namespace": "my::app::caching"
},
"bp-caching": {
"impl": "cds-caching",
"namespace": "my::app::bp-caching"
}
}
}
}
Advanced Configuration
{
"cds": {
"requires": {
"caching": {
"impl": "cds-caching",
"namespace": "my::app::caching",
"store": "in-memory", // "in-memory" or "redis"
"compression": "lz4", // "lz4" or "gzip"
"credentials": { // if store is redis
"host": "localhost",
"port": 6379,
"password": "optional",
}
}
}
}
}
Low level usage
// Connect to the caching service
const cache = await cds.connect.to("caching")
// Store a value
await cache.set("key", "value")
// Retrieve the value
await cache.get("key") // => value
// Check if the key exists
await cache.has("key") // => true/false
// Delete the key
await cache.delete("key")
// Clear the whole cache
await cache.clear()
Cache Events
- value – Contains the cached data.
- tags – Contains tags assigned to the cached entry.
- timestamp – Contains the timestamp when the cache entry was created.
// Log before the cache is cleared
cache.before("CLEAR", () => {
console.log("Cache is about to be cleared")
})
// Log before storing data
cache.before("SET", (event) => {
console.log(`Storing key: ${event.data.key} with value: ${event.data.value}`)
})
// Log after retrieving data
cache.after("GET", (event) => {
console.log(`Retrieved key: ${event.data.key} with value: ${event.data.value}`)
})
Invalidation via Time-To-Live (TTL)
// Store a value with a ttl
await cache.set("key", "value", { ttl: 6000 }) // 60 seconds
// Retrieve the value in time
await cache.get("key") // => value
await new Promise((resolve) => setTimeout(resolve, 6100)) // wait 61 seonds
// Now the value is not available anymore
await cache.get("key") // => undefined
Compression
- lz4 – Faster compression and decompression, ideal for performance-critical applications.
- gzip – Higher compression ratio, reducing storage footprint at the cost of slightly increased CPU usage.
{
"cds": {
"requires": {
"caching": {
"impl": "cds-caching",
"compression": "lz4"
}
}
}
}
Medium level usage
Caching CQN queries
// Create the CQN object
const query = SELECT.from(Foo)
// Execute to fetch the result
const result = await cds.run(query) // => [{...}, {...}]
// Store value in the cache
await cache.set(query, result)
// Retrieve the value from the cache using the same CQN object
const cachedResult = await cache.get(query) // => [{...}, {...}]
// Create the key that is used internally
const key = cache.createKey(query)
// Delete the value from the cache
await cache.delete(query)
Caching cds.Requests
- req.tenant – Ensures data is scoped per tenant in multi-tenant environments.
- req.user – Allows user-specific caching when necessary.
- req.locale – Supports localized responses when caching multilingual content.
this.on('READ', BusinessPartners, async (req, next) => {
const bupa = await cds.connect.to('API_BUSINESS_PARTNER')
let value = await cache.get(req)
if(!value) {
value = await bupa.run(req)
await cache.set(req, next, { ttl: 3600 })
}
return value
})
- Data Inconsistency – OData services expose live business data, which frequently changes. Caching responses without an appropriate invalidation strategy can lead to outdated or incorrect data being served.
- Complex Query Variations – OData allows dynamic query parameters ($filter, $expand, etc.), making it difficult to cache efficiently without storing excessive variations.
- Large Payloads – Full OData responses can be significantly large, consuming cache memory inefficiently compared to caching targeted CQN queries or specific request results.
Read-through CQN queries and cds.Requests
- cache.run – Executes CQN queries or requests against a database or remote OData service.
see: CAP Documentation on srv.run(query) - cache.send – Sends custom synchronous requests (to REST APIs) with configurable paths and headers.
see: CAP Documentation on srv.send(request)
this.on('READ', BusinessPartners, async (req, next) => {
const bupa = await cds.connect.to('API_BUSINESS_PARTNER')
return cache.run(req, bupa, { ttl: 3600 })
})
Some other examples:
// Read-through for a CQN query
const queryResult = await cache.run(SELECT.from("Foo"), db, { ttl: 360 })
// Read-through for a custom REST request
const restService = await cds.connect.to({
"kind": "rest",
"credentials": {
"url": "https://services.odata.org/V3/Northwind/Northwind.svc/"
}
});
const restResult = await cache.send({ method: "GET", path: "Products" }, restService, { ttl: 3600 });
Wrapping async complex code
const expensiveFunction = async (param) => { /* Do something complex */ }
// Wrap the function with caching
const cachedExpensiveFunction = await cache.wrap("key", expensiveFunction, { ttl: 360 })
// First call executes the function
result = await cachedExpensiveFunction("someParam"); // No cache hit
// Subsequent calls retrieve the result from cache
result = await cachedExpensiveFunction("someParam"); // Cache hit
Advanced level Usage
Phil Karlton
Handling cache keys
- Smartly hashing the given key parameter.
- Adding metadata (e.g., request information) where applicable.
- Ensuring that *each query variation (including WHERE clauses, etc.) generates a unique cache entry.
- Incorporating request context into keys (e.g., tenant, user, locale) for better cache isolation.
// No key override given, string will just be used as keys
await cache.set('key', 'value') // key: key
// No key override given, objects will be smartly hashed
await cache.set(SELECT.from(Foo)) // key: bd3f3690d3e96a569bd89d9e207a89af
// Automatically build the key for retrieval/deletion
cache.createKey(SELECT.from(Foo)) // key: bd3f3690d3e96a569bd89d9e207a89af
// Override and use your own key based on a fixed value
await cache.set(SELECT.from(Foo, 1), { key: { value: "foo:1" } })
// Override and only for requests, use request context information
await cache.set(req, { key: { template: "mykey:{tenant}:{user}:{locale}:{hash}" } })
// This requests will be cached for all users and for each locale
await cache.set(req, { key: { template: "mykey:{user}:{locale}:{hash}" } })
- value – generates a static value
- prefix – will add this piece at the beginning
- suffix - will ad this piece at the end
- template - will set a value filled with placeholders, available placeholders are (only relevant for requests)
- tenant
- locale
- user
- hash (generated hash based on the incoming query, params, data, path, etc.)
Managing Cache Tags
A good strategy when using tags is to also use multiple caching service instances to avoid iterating over large cache datasets.
- value – Static tag value.
- prefix – Prepends a static prefix.
- suffix – Appends a static suffix.
- data – Uses a field value from the dataset dynamically. This works for single entities as well as for arrays of objects.
- param – Uses a request parameter dynamically.
- template – Uses placeholders (tenant, locale, user, hash) for dynamic tag generation.
const data = [
{ ID: 1, title: 'First Book' },
{ ID: 2, title: 'Second Book' },
{ ID: 3, title: 'Third Book' },
]
const tagConfig = [
// Static tag
{ value: 'books' }, // => books
// Dynamic tag
{ data: 'ID', prefix: 'book::' }, // => ['book::1', 'book::2', 'book::3']
]
// Tags will contain a flat array of generated tags
const tags = cache.resolveTags(tagConfig, data) // ["books", "book::1", "book::3", "book::3"]
// Automatically handles tags and stores them along the key entry
await cache.set("key", data, { tags: tagConfig })
// Delete all cache entries with this cache
await cache.deleteByTag("book::3")
// Returns all assigned tags
await cache.tags("key") // => ["books", "book::1", "book::3", "book::3"]
// Returns the tags and the cache timestamp
await cache.metadata("key") // => { tags: ["books", "...], timestamp: 1231234323 }
Iteration
for await (const [key, value] of cache.iterator()) {
if (key.match(new Regex(`bp:${businessPartner}(.*)`))) {
await cache.delete(key)
}
}
Annotations for Entity and Function caching
using {db} from '../db/model';
using {API_BUSINESS_PARTNER} from './external/API_BUSINESS_PARTNER.csn';
service AppService {
entity Foo as projection on db.Foo actions {
@cache: {
service : 'caching',
key: { template: '{hash}' }
tags: [
{ param: 'param1', prefix: 'param1-' },
{ data: 'ID', prefix: 'foo-' },
]
}
function getBoundCachedValue(param1 : String) returns String;
};
@cache: {
service : 'caching-bp',
ttl : 0,
tags: [{ prefix: 'bp:', field: 'BusinessPartner' }]
}
@readonly
entity BusinessPartners as projection on API_BUSINESS_PARTNER.A_BusinessPartner;
}
class AppService extends cds.ApplicationService {
async init() {
const { BusinessPartners } = this.entities;
const bupa = await cds.connect.to("API_BUSINESS_PARTNER");
this.on('READ', BusinessPartners, async (req) => {
return bupa.run(req.query);
});
this.on('getBoundCachedValue', async (req) => {
return `cached ${req.param.param1}`;
});
return super.init()
}
}
- Use annotations for internally complex functions, as they have limited permutations.
- Cache entire services only if:
- The (external) service is extremely slow.
- API access is limited to a few predefined requests.
Cache pre-warming
// Schedule the pre-warming every hour
cds.once("served", () => {
cds.spawn ({ every: 3_600_000 /* hour */ }, async (tx) => {
const cache = cds.connect.to("caching")
const AppService = cds.connect.to("AppService")
const relevantEntities = await SELECT.from(Foo).where({ relevant: true })
for(const foo of relevantEntities) {
for (const param of ["param", "param1", "param2"]) {
const req = new cds.Request({
locale: "en",
tenant: "t0",
user: cds.User.Privileged,
event: 'getBoundCachedValue',
data: [foo.ID],
params: { param1: param }
})
// Cache
await cache.run(req, AppService})
}
}
})
})
Keeping the cache fresh with event-driven cache invalidation
messaging.on("sap.s4.beh.businesspartner.v1.BusinessPartner.Changed.v1", async (event) => {
const cache = await cds.connect.to("bp-cache")
// Delete all cached data for this specific business partner so it can get re-generated
await cache.deleteByTag(`bp:${event.data.BusinessPartner}`)
})
Measuring Cache performance
Using cds-caching in real-world applications
- Simple and fast, but not persistent.
- Not suitable for production since Node.js runtime memory is limited.
- Data is lost when the application restarts.
- Persistent and supports distributed caching.
- Works across multiple app instances, making it ideal for scalable applications.
- Available on SAP BTP via hyperscaler options (e.g., AWS, Azure, Google Cloud).
- Even trial accounts provide Redis access.
Running Redis Locally via Docker
Create a docker-compose.yml file with the following configuration:
services:
redis:
image: redis:latest
container_name: local-redis
ports:
- "6379:6379"
"caching": {
"impl": "cds-caching",
"namespace": "myCache",
"store": "redis",
"[development]": {
"credentials": {
"host": "localhost",
"port": 6379
}
}
}
Running with Redis on SAP BTP
👉Tip: There is a detailed blog series on Redis in SAP BTP explaining how to set up Redis and connect via SSH for local/hybrid testing, as this is by default not possible.
modules:
- name: cap-app-srv
...
requires:
...
- name: redis-cache
...
resources:
- name: redis-cache
type: org.cloudfoundry.managed-service
parameters:
service: redis-cache
service-plan: trial
service-tags:
# Must match the kind property in the package.json
- cds-caching
Final Verdict: Is cds-caching Right for You?
- Frequent API calls to remote services with high latency.
- Expensive database queries that don’t change frequently.
- Expensive and complex calculations at runtime.
- Static or master data that is accessed often but updated rarely
- High traffic scenarios where response times need to stay low.
- When data changes frequently and stale cache entries could cause issues.
- When every request needs fresh data .
- When there is no clear cache invalidation strategy in place.