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
Installing and using cds-caching is straightforward since it’s a CAP plugin. Simply run:
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
For more control, you can specify additional options. Some of those will be explained later:
{
"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
The cds-caching plugin provides direct
key-value storage, allowing fine-grained caching control. Its API follows a familiar pattern, making it easy to use if you have ever worked with other caching solutions and frameworks (e.g. the
cache manager in Nest.js).
Here’s how you can interact with the cache:
// 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()
This low-level API is useful when you need direct access to cached data, such as storing configuration values, or precomputed results. While the core API is simple, cds-caching also provides higher-level caching strategies that are more integrated with CAP. We will look at those in a minute. Before, let's focus on cache events.
Cache Events
The caching service follows the same event-driven principles as cds.Service instances. This means you can hook into events before and after storing or retrieving data. This is useful for logging, debugging, or performing additional actions when cache operations occur.
Each cache event contains the key (event.data.key) and data (event.data.value). Because cds-caching also has support for tags (which we will cover later), the data is a structure:
- value – Contains the cached data.
- tags – Contains tags assigned to the cached entry.
- timestamp – Contains the timestamp when the cache entry was created.
Here’s how you can listen for and react to cache events:
// 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}`)
})
This approach allows you to monitor cache activity and, if needed, manipulate data before storage or retrieval.
Invalidation via Time-To-Live (TTL)
cds-caching supports automatic cache invalidation via TTL (Time-To-Live). Cached values will expire after the specified TTL, preventing stale data from lingering in memory.
// 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
TTL-based invalidation is useful for temporary data, rate-limiting mechanisms, and frequently updated information. However, cds-caching also supports other invalidation strategies, which will be covered in the advanced section.
Compression
To optimize storage, cds-caching supports data compression. This reduces cache size and can improve performance, especially when caching large datasets. Compression is applied only when storing data, meaning applications interact with uncompressed values. Available compression methods include:
- 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.
Compression can be configured via package.json:
{
"cds": {
"requires": {
"caching": {
"impl": "cds-caching",
"compression": "lz4"
}
}
}
}
Medium level usage
One of the core principles of cds-caching is seamless integration with CAP, aligning with CAP’s design and query execution model. As a result, cds-caching provides native support for CQN (Core Query Notation) queries and cds.Requests.
Caching CQN queries
CQN queries are widely used in CAP for database operations and remote service calls (e.g., OData requests). Since these queries often involve repeated data retrieval, caching them can significantly reduce response times.
cds-caching treats CQN queries as first-class objects, allowing them to be passed directly into the caching API. Internally, cds-caching generates a unique key based on the CQN query structure, ensuring consistent retrieval and cache integrity.
// 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)
While CQN queries can be cached directly, cds-caching also supports caching full cds.Requests, including their request context.
Caching cds.Requests
Caching requests is particularly useful when exposing remote services through local CAP services. For example, if your CAP application proxies an external API, caching can significantly reduce redundant requests and improve response times.
cds-caching automatically includes contextual information in the cache key, making request caching tenant- and user-aware. The cache key incorporates:
- 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
})
⚠️When not to cache full OData services
While caching individual requests can improve performance, caching an entire OData service (in fact, cds-caching will only tackle READ/SELECT requests and ignore all others) is generally not recommended. Here’s why:
- 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.
👉Best Practice: Instead of caching entire OData service responses, cache specific functions, queries or request results where the data is frequently accessed and doesn’t change often. For example, focussing on remote services, static master data, or computed results is much safer and more efficient than blindly caching an entire service.
Read-through CQN queries and cds.Requests
While the basic caching API is useful, it follows the read-aside cache pattern, meaning manual cache checks are required before fetching and storing data. In scenarios where caching logic becomes repetitive, higher-level caching strategies can help streamline this process and thanks to the already available CAP API, those are also available in cds-caching.
In read-through caching, queries and requests are executed through the caching service itself, reducing the need for manual cache handling. cds-caching re-uses CAP’s built-in service methods to apply read-through caching behavior:
Using read-through caching, the previous read-aside pattern example can be reduced to a single line of code:
this.on('READ', BusinessPartners, async (req, next) => {
const bupa = await cds.connect.to('API_BUSINESS_PARTNER')
return cache.run(req, bupa, { ttl: 3600 })
})
With this approach, all SELECT requests to the external service will be automatically executed and cached, eliminating the need for manual cache handling.
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 });
The read-through strategy makes caching cleaner and more maintainable, as it abstracts cache management entirely. However, the first request is still slow (since there’s no cached value yet), but all subsequent requests will be served instantly from the cache.
Wrapping async complex code
The read-through approach can also be applied to non-CAP-specific operations. cds-caching provides a wrap function that caches the result of any asynchronous function.
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
This is particularly useful for complex and long running operations, ensuring they only need to be executed once per TTL period.
Advanced level Usage
Caching is more than just storing and retrieving data. It requires well-thought-out strategies for managing keys, invalidation, and consistency Depending on the requirements, these strategies can range from simple TTL-based approaches to complex cache tagging and event-driven updates.
It’s almost impossible to discuss caching without mentioning the following quote:
“There are only two hard things in Computer Science: cache invalidation and naming things.”
Phil Karlton
And from my personal experience, this is true. Both is! 😄
Handling cache keys
Effective cache key management is essential for avoiding conflicts, ensuring uniqueness, and enabling efficient invalidation. cds-caching automatically creates MD5 hash-based keys by:
- 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.
Keys are critical for cache invalidation. To allow custom key management, you can override the auto-generated key. This option is available for all essential methods (e.g cache.set, cache.run, cache.send, cache.createKey) and for the annotations (check further below).
// 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}" } })
Overriding keys support the following configuration options:
- 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.)
With well-structured keys, invalidating cache entries becomes a lot easier. However, for more complex scenarios tags provide an even more effective solution, as tags can automatically be created based on the cached data.
Managing Cache Tags
Tags group related cache entries, allowing batch invalidation instead of handling individual keys. This is really important when looking at query and request caching, where a single entity can be part of multiple cache entries. While this offers great flexibility, tags also come with a price as tags consume additional cache storage and deleting by tag requires iterating over all cache entries. So it should be used wisely.
A good strategy when using tags is to also use multiple caching service instances to avoid iterating over large cache datasets.
Tags can be dynamically generated like keys, but additionally based on data, making cache invalidation more precise. Supported configurations:
- 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.
An example:
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")
Tags make cache invalidation more efficient — for example, when a single book entry changes, all related cached queries containing that book can be invalidated in one step.
cds-caching also offers methods to introspect a cache entry and check for its tags:
// 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
But even without using tags, caches can be handled dynamically. cds-caching is providing an async-iterator, that enables looping through all cache entries. A use case for this is pattern-based invalidation, where cache entries can be iterated and removed selectively.
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
As mentioned above, caching entire OData services is not recommended, as the variety of query parameters ($filter, $expand etc.) makes it difficult to manage cache consistency and can generate a huge amount of duplicated data. However, as there might be cases where it still makes sense, cds-caching provides annotations for entities and functions, enabling read-through caching at the service level.
The following example shows an annotated OData service with a read-through-cached function and an internally exposed entity from a remote service.
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;
}
The implementation of that service would then look like this:
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()
}
}
As you can see, the implementation isn't involving cds-caching at all and the caching is fully handled in the background. Because we create tags in both scenarios, we can selectively identify cache entries for entities by tag (e.g. foo-1 or bp:1) or parameter (e.g. param:test) and remove cache items whenever the respected entity changes. But again, caching entire entities can raise new problems.
📔Best Practice
- 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.
Otherwise, replication might be better alternative — more on that in an upcoming blog post and (potentially) a CAP plugin in the future. 😉
Cache pre-warming
Cache pre-warming is a technique where cache entries are preloaded before the first user request, reducing initial response times. Instead of waiting for the first query to trigger data retrieval and caching, a background process proactively fills the cache with relevant data. However, pre-warming requires accurate predictions of which data will be frequently accessed. If the wrong data is preloaded, cache memory may be wasted on unnecessary entries. Additionally, since pre-warmed data does not update automatically, proper cache invalidation strategies must be implemented to keep the cache fresh and avoid serving outdated information.
For lightweight
pre-warming tasks,
cds.spawn provides a simple way to schedule jobs in a CAP application. The following example runs a background process every hour to fetch relevant data and store it in the cache based on the annotated example before:
// 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})
}
}
})
})
For
enterprise-scale applications or
multi-tenant scenarios,
SAP BTP’s Job Scheduling Service provides a more robust solution for cache pre-warming. It supports multi-tenancy, ensuring cache preloading per tenant, and allows scheduling across
multiple app instances for consistency. Additionally, it offers a
centralized UI for managing and monitoring scheduled jobs. This makes it ideal for automating
periodic preloading of frequently used datasets, such as
daily batch processing or
pre-warming data before business hours, ensuring optimal performance across the system.
Keeping the cache fresh with event-driven cache invalidation
A well-designed caching strategy is not just about storing data efficiently but also about keeping it fresh and consistent. In many cases, data in the cache can become stale if it is not updated when the underlying data changes. Manually managing cache invalidation can be challenging, especially in dynamic applications where multiple users or services modify the same data. This is where event-driven cache invalidation comes into play. CAP’s event-driven architecture makes it an excellent fit for real-time cache updates, allowing cache entries to be automatically refreshed whenever relevant data changes, event in remote systems. This ensures that applications do not serve outdated information while still benefiting from performance improvements.
By leveraging internal CAP events or external event brokers like SAP Event Mesh, applications can trigger cache invalidation as soon as a data update occurs, making the cache more reliable and reducing the risk of inconsistencies.
Example: Cache Invalidation on Business Partner Update
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
A well-implemented caching strategy should not only improve performance but also be measurable to ensure it’s working as expected. cds-caching provides integrated support for collecting cache metrics, allowing developers to monitor cache efficiency, track usage patterns, and optimize their caching approach.
But as this feature is currently still work in progress, you won't find more information on this here, but (soon) in
GitHub repository.
Using cds-caching in real-world applications
While cds-caching provides many powerful features, caching requires a well-defined strategy and is not necessary for all applications. However, in the right scenarios it can be a lifesaver in terms of performance and scalability. cds-caching provides two storage options:
In-Memory Cache (for small-scale use)
- Simple and fast, but not persistent.
- Not suitable for production since Node.js runtime memory is limited.
- Data is lost when the application restarts.
Redis Cache (recommended for production)
- 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
For local development, Redis can be quickly set up using Docker. A simple docker-compose configuration provides a lightweight caching environment:
Create a docker-compose.yml file with the following configuration:
services:
redis:
image: redis:latest
container_name: local-redis
ports:
- "6379:6379"
Run Redis with:
docker-compose up -d
Modify the package.json configuration to connect to the local Redis instance:
"caching": {
"impl": "cds-caching",
"namespace": "myCache",
"store": "redis",
"[development]": {
"credentials": {
"host": "localhost",
"port": 6379
}
}
}
Now, caching will be handled by Redis instead of in-memory storage during development.
Running with Redis on SAP BTP
For
production deployments on SAP BTP, Redis can be provisioned as a
managed service as it is available on SAP as a service:
Redis on SAP BTP, hyperscaler option. An instance can be provisioned via trial or even as a Free Tier to explore the service. However, for production scenarios the size of the Redis instance should match your caching requirements.
For integrating Redis on BTP, there is nothing more to do then just setting up the proper configuration.
👉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.
To bind Redis to your CAP application on SAP BTP, simply add the following configuration in mta.yaml. This will automatically create the service instance and bind your application to it. Since the credentials will automatically be fetched by CAP, make sure to maintain the service-tags to match the kind property of your cds-caching service(s) in the package.json:
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?
Caching can dramatically improve performance, but it is not a one-size-fits-all solution. The decision to use caching should be based on real-world bottlenecks and a clear invalidation strategy.
✅ When to Use cds-caching?
- 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 Not to Use Caching?
- 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.
And in terms of storage: For small-scale applications, in-memory caching may suffice, but for scalable, production-grade deployments, Redis is the way to go!
Since cds-caching is open-source and freely available, feel free to explore it and see if it fits your needs. If you have any questions, feedback, or suggestions, I’m happy to help—just reach out!