Node.js Caching Strategies: Improve Performance and Scalability

Node.js Caching Strategies: Improve Performance and Scalability. Caching is a process of storing data in a cache memory, a high speed storage layer that serves data faster. The data stored in a cache is usually a duplicate of data stored elsewhere, or from an earlier computation. When you store data in a cache, the system doesn’t have to perform computations, which results in fast responses.

In Node.js, caching improves the application’s response times. The cache sits between the data source and the application, intercepting requests and returning responses promptly. The user request doesn’t have to reach the backend server for processing. This results in fast responses and reduces the amount of data transferred over the network.

This article walks you through Advanced Node.js Caching Strategies: Improve Performance / Scalability. Read on!

Why Cache Data?

1. Speed

Caching provides fast data access by storing copies of frequently used data. This results in quicker data retrieval and improved user experiences.

2. Reduced Strain on System Resources

Primary data sources like databases use considerable system resources to process requests. Caching reduces the number of direct queries, reducing the strain on CPU and memory. 

3. Cost Efficiency

In cloud setups, operations on primary data sources often come with data retrieval costs. But caching reduces your costs especially if your application performs frequent data access.

4. Offline Access

During maintenance and unexpected events, systems occasionally face downtime. However, with caching, data remains accessible even if the primary source is offline, thus ensuring uninterrupted services.

Caching Patterns and Strategies in Node.js

1. In Memory Caching

In memory caching is a technique where data is stored directly in the application’s process memory. This allows for fast access and retrieval, bypassing expensive computations or database/API calls. Node.js, being a server-side runtime, is inherently single-threaded. This makes in-memory a simple caching strategy since there’s no need for thread to thread communication.

In Node.js, you implement in-memory caching using the memory-cache npm module. Here is a basic implementation demonstrating how to utilize the memory-cache module:

				
					const cache = require('memory-cache');

function getDataFromCache(key) {
  const cachedData = cache.get(key);
  if (cachedData) {
    return cachedData;
  }

  const data = fetchDataFromSource();
  cache.put(key, data, 60000); // Cache for 60 seconds
  return data;
}

				
			

When getDataFromCache is called, it tries to fetch data from cache using the provided key. If found, it returns the cached data. If not, it fetches the data from a source (like a database or an API), caches it for subsequent use, and then returns it.

2. Write Through Cache Pattern

The write through caching pattern allows data to be written to both the cache and the main data store simultaneously. This method ensures consistency between the two, and comes in handy when the system needs maximum consistency between the cache and the main data store.

In the write through cache pattern, the cache is updated instantly when the primary database is updated. Besides, you implement it together with the lazy loading pattern, which fetches data from the main store and populates the cache in cases of cache misses.

Implement the write through strategy in two ways. Update the cache first, and then the database or update the database first, and then the cache.

Here’s a sample implementation of the write-through cache pattern in Node.js:

				
					const cache = require('simple-cache-library');
const db = require('simple-db-library');

// Update cache first, then the database
function updateCustomer_cacheFirst(customerId, customerData) {
  cache.writeThrough(customerId, customerData, cache.defaultTTL, async (key, value) => {
    try {
      await db.save(key, value);
    } catch (error) {
      console.error("Failed to update the database after updating the cache:", error);
    }
  });
}

// Update the database first, then the cache
async function updateCustomer_dbFirst(customerId, customerData) {
  try {
    const record = await db.findAndUpdate(customerId, customerData);
    cache.set(customerId, record, cache.defaultTTL);
  } catch (error) {
    console.error("Failed to update the customer:", error);
  }
}

				
			

The primary goal of the write through cache pattern is to maintain data consistency between the cache and the primary data store. This strategy can add some latency due to simultaneous writes to both cache and database. However, it ensures that the cache always contains fresh data. This greatly benefits read heavy operations.

3. Cache Aside Pattern

Well, the cache aside pattern requires the application to manage both loading the data into the cache and removing it when no longer needed. This way, the cache is always filled with only the data that is needed and accesses data in on demand. Hence, this method is also known as lazy loading.

Here, the application code is responsible for loading data into the cache, updating, and evicting it. The cache aside pattern interacts with the cache only when necessary. This is unlike the write through or write behind caching patterns where data is automatically written to cache on every write operation. 

In this caching strategy, data is only loaded into the cache explicitly by the application when there is a cache miss. When there is an update in the data store, it’s the application’s responsibility to invalidate the cache entry. In turn, it ensures the cache does not serve stale data. Over time, if there is no frequent data access, it might be evicted from the cache.

All in all, Node.js being asynchronous by nature, suits the cache-aside pattern, especially when data fetching is I/O intensive. Below is a basic cache aside pattern implementation in Node.js:

				
					const cache = require('simple-cache-library');
const db = require('simple-db-library');

// Fetching data using Cache-Aside Pattern
async function fetchData(key) {
  // First, try to fetch data from the cache
  let data = cache.get(key);

  // If data is not found in the cache (cache miss)
  if (!data) {
    // Fetch the data from the primary data store (e.g., a database)
    data = await db.getData(key);

    // Once data is retrieved, store it in the cache for future use
    cache.set(key, data, cache.defaultTTL); 
  }

  // Return the fetched data
  return data;
}

// Using the function to get data
(async () => {
  const keyToQuery = "someUniqueKey";
  const data = await fetchData(keyToQuery);
  console.log(data);
})();

				
			

4. Write Behind Pattern

Next, the write back caching strategy is an approach to data synchronization between a cache and its backing data store. The primary goal of this technique is to boost application performance by deferring updates to the underlying storage.

So, when a write operation occurs, instead of immediately writing to the primary data store, the data is first updated or written into the cache. Then, the update to the primary data store is deferred to a later time. This delay could be based on certain triggers such as time intervals, specific events, or when a certain number of cache items have changed.

Often, you can batch together multiple updates and write to the primary data store in a single operation to further optimizing performance. Write back also reduces latency since immediate writes are made to the cache and not the slower primary data store. Also, there is less load on the primary data store. Since updates are batched, there is a less number of write operations hitting the primary data store. This in handy, where the primary data store is sensitive to high write loads.

Simple Implementation of the Write Behind Pattern in Node.js

				
					const EventEmitter = require('events');

// Represents a user's profile with an ID and a name.
class UserProfile {
    constructor(id, name) {
        this.id = id;
        this.name = name;
    }
}

// Represents a simple cache mechanism using a Map.
class Cache {
    constructor() {
        this.data = new Map();
    }

    get(userId) {
        return this.data.get(userId);
    }

    set(userProfile) {
        this.data.set(userProfile.id, userProfile);
    }
}

// Represents a mock database using Node's EventEmitter for demonstration purposes.
class Database extends EventEmitter {
    constructor() {
        super();
        this.data = new Map();
    }

    get(userId) {
        return this.data.get(userId);
    }

    update(userProfile) {
        setTimeout(() => {
            this.data.set(userProfile.id, userProfile);
            this.emit('updated', userProfile);
        }, 1000);
    }
}

// Manages user profiles, making use of caching and the mock database.
class UserProfileManager {
    constructor() {
        this.cache = new Cache();
        this.db = new Database();

        // Listen for the 'updated' event from the database and log a message.
        this.db.on('updated', (userProfile) => {
            console.log(`Database updated for user ${userProfile.id}`);
        });

        // This part is optional as our mock database doesn't emit an 'error' event.
        // But it's here for demonstration.
        this.db.on('error', (error) => {
            console.error("Failed to update database:", error);
        });
    }

    updateUserProfile(userProfile) {
        this.cache.set(userProfile);
        this.db.update(userProfile);
    }
}

// Demonstration of usage:
const manager = new UserProfileManager();
manager.updateUserProfile(new UserProfile(1, "Dennis"));

				
			

5. Read Through Pattern

Another is read through caching, a method where the cache itself is responsible for both serving data and populating itself when it encounters a cache miss. When a client requests data, it queries the cache. If the data isn’t in the cache (a cache miss), the cache retrieves the data from the primary data store and then serves it to the client, ensuring it’s also saved in the cache for subsequent requests.

This differs from cache aside, where the application checks the cache, and if there’s a miss, the application itself fetches data from the database, caches it, and then serves it. In read through, the cache is the primary interface for data retrieval.

Here is a simple implementation of read through caching pattern in Node.js:

				
					const EventEmitter = require('events');

class ReadThroughCache extends EventEmitter {
    constructor() {
        super();
        this.data = new Map();
        this.ttl = new Map(); // for simulating Time-To-Live values
    }

    // Simulate reading from a database
    async dbGet(key) {
        // In a real scenario, this would interact with a database.
        return `Data for ${key}`;
    }

    async readThrough(key, ttlValue) {
        if (this.data.has(key)) {
            return this.data.get(key);
        } else {
            // Cache miss scenario
            let data = await this.dbGet(key);
            this.data.set(key, data);
            
            // Setting TTL (Optional)
            this.ttl.set(key, ttlValue);
            setTimeout(() => this.data.delete(key), ttlValue);

            return data;
        }
    }
}

const cache = new ReadThroughCache();

(async function demo() {
    console.log(await cache.readThrough('user1', 5000)); // Fetches from "database", sets in cache
    console.log(await cache.readThrough('user1', 5000)); // Fetches from cache
})();

				
			

Caching in Node.js With Redis and Memcached

Both Redis and Memcached are ideal caching solutions that you integrate with a Node.js application. Redis is an in-memory data store that functions as a cache. Due to its in-memory nature, Redis handles a high number of operations per second, making it suitable for high performance Node.js applications. Memcached is an open-source, high-performance, distributed memory caching system ideal for dynamic web applications.

Node.js Caching With Redis

To start the caching process, ensure you have both Redis and Node.js running on your system. To install Redis, use this command:

				
					sudo apt-get install redis-server
				
			

Start your Redis server with this command:

				
					sudo service redis-server start
				
			

Initialize a simple Node.js application:

				
					mkdir node-redis-cache
cd node-redis-cache
npm init -y
npm install express axios redis superagent
				
			

Proceed to create an Express.js server. A basic implementation:

				
					const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`Server listening at http://localhost:${PORT}`);
});
				
			

Following, establish a Redis connection. Well, Redis connects with Node.js using the node-redis module which allows you to store and retrieve data in Redis.

				
					const redis = require('redis');
const client = redis.createClient();

client.on('error', (error) => {
  console.error('Redis error:', error);
});

At this point, we can fetch and cache data. For demonstration purposes, we can use the JSONPlaceholder API and GitHub's public API:

const axios = require('axios');

app.get('/users', async (req, res) => {
  try {
    const response = await axios.get('https://jsonplaceholder.typicode.com/users');
    res.json(response.data);
  } catch (error) {
    console.error(error);
    res.status(500).json({ message: 'Error fetching users.' });
  }
});

				
			

Then, to implement the caching logic use middleware functions and route-specific logic, implement caching:

				
					const CACHE_KEY = 'users';

app.get('/users', cacheMiddleware, async (req, res) => {
  try {
    const response = await axios.get('https://jsonplaceholder.typicode.com/users');
    client.setex(CACHE_KEY, 3600, JSON.stringify(response.data));
    res.json(response.data);
  } catch (error) {
    console.error(error);
    res.status(500).json({ message: 'Error fetching users.' });
  }
});

function cacheMiddleware(req, res, next) {
  client.get(CACHE_KEY, (err, data) => {
    if (err) throw err;
    if (data) return res.json(JSON.parse(data));
    next();
  });
}

				
			

Express middleware is ideal if you want reusable caching logic. Middleware has access to the request, response, and the next function in the request response lifecycle.

The above is just a basic implementation. Implement caching in advanced Node.js applications and achieve even better performance.

Caching Node.js Application With Memcached

 First, ensure you’ve installed Memcached and it’s running:

				
					sudo apt-get install memcached
				
			

Start the server with the following command:

				
					memcached -p 11211
				
			

Use this command to initialize Node.js application, if you don’t have one already.

				
					npm init -y
				
			

Then, install the Memcached module:

				
					npm install memcached
				
			

Create a new file index.js and establish a connection to Memcached:

				
					const Memcached = require('memcached');
const memcached = new Memcached('localhost:11211');
				
			

Here is a basic implementation of how to store and retrieve data in Memcached:

				
					// Storing data
memcached.set('keyName', 'sampleValue', 10, function(err) {
    if(err) throw err;
    console.log('Data stored');

    // Retrieving data
    memcached.get('keyName', function(err, data) {
        if(err) throw err;
        console.log(data);  // Outputs: 'sampleValue'
    });
});
				
			

Set expiration times to the stored data after a certain time, for instance, 500 seconds:

				
					memcached.set('keyName', 'sampleValue', 500, function(err) {
    if(err) throw err;
    console.log('Data stored for 5 minutes');
});
				
			

Remove data from Memcached:

				
					memcached.delete('keyName', function(err) {
    if(err) throw err;
    console.log('Data removed');
});

				
			

Implement error handling:

				
					memcached.on('error', function(err) {
    console.error('Memcache error:', err);
});
				
			

When you run node index.js, it executes the caching operations you’ve set up. However, this is just a basic implementation. To further optimize configurations it will depend on your application needs and to implement monitoring for optimal application performance.

Thank you for reading our article Node.js Caching Strategies: Improve Performance and Scalability. Let’s summarize.

Node.js Caching Strategies: Improve Performance and Scalability Conclusion

In summary, caching helps improve the performance and stability of your Node.js application. By understanding and implementing various caching patterns and strategies, you significantly reduce load times and improve user experiences significantly. Whether you’re using in-memory caching, or applying patterns like write-through and cache aside, it’s crucial to evaluate your application needs and choose the best strategy that fits.

Both, Redis and Memcached provide in memory storage for Node.js applications to reduce the time taken to access frequently requested data.  However, it’s important to optimize your implementations to match application demands. For optimal performance, ensure you stay up to date with the latest caching strategies.

Avatar for Dennis Muvaa
Dennis Muvaa

Dennis is an expert content writer and SEO strategist in cloud technologies such as AWS, Azure, and GCP. He's also experienced in cybersecurity, big data, and AI.

0 0 votes
Article Rating
Subscribe
Notify of
0 Comments
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x
()
x