Implementing Caching In Node.js: Best Caching Strategies

12 min read

Cover Image for Implementing Caching In Node.js: Best Caching Strategies

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 very fast responses and reduces the amount of data transferred over the network.

This article walks you through the various caching methods in Node.js. Read on!

Why Cache Data?

1. Speed

Caching provides fast data access by storing copies of frequently used data. This eliminates the need for time consuming process database querying, and results in quicker data retrieval and improved user experiences.

2. Reduced Load

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. By reducing the number of operations through caching, you reduce 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.

Node Js Caching Patterns and Strategies

Here are some of the top node caching strategies in Node.Js:

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 can 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.

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 can implement it together with the lazy loading pattern, which fetches data from the main store and populates the cache in cases of cache misses.

You can implement the write through strategy in two ways. Either you 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. Tthis 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 can greatly benefit read heavy operations.

Cache Aside Pattern

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. This method is also known as lazy loading.

In the cache aside pattern, 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. This ensures the cache does not serve stale data. Over time, if there is no frequent data access, it might be evicted from the cache.

Node.js, being asynchronous by nature, suits the cache-aside pattern, especially when data fetching can be 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);
})();

Write Back Strategy

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.

When a write operation occurs, instead of immediately writing to the primary data store, the data is first updated or written into the cache. 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 comes in handy in systems where the primary data store is sensitive to high write loads.

Here is a 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, "Alice"));

Read Through Pattern

Read through caching is 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
})();

Least Recently Used (LRU) Caching Strategy

The Least Recently Used (LRU) cache algorithm is a type of cache eviction strategy. In computing, caching is used to store data so that future requests for that data can be served faster. When the cache reaches its limit, it's necessary to decide which items to remove in order to make space for new data. LRU is one such decision strategy.

The LRU algorithm evicts the least recently used items first. This approach assumes that an item that hasn't been used in a while is less likely to be used in the near future compared to items that have been accessed more recently.

LRU keeps track of the order in which elements are accessed. When an element is accessed (either read or written), it's moved to the front, indicating it's the most recently used item. When the cache reaches its capacity and a new item needs to be loaded into the cache, the item at the end (the least recently used item) is the one that gets evicted. If an existing item is updated, its position is refreshed and it's moved to the front as the most recently accessed item.

Caching in Node.js With Redis and Memcached

Both Redis and Memcached are ideal caching solutions that you can 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 can handle 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.

Below is an handy guide on how to cache Node.js applications with both Redis and Memcached:

Node Js Caching With Redis

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

sudo apt-get install redis-server

To start your Redis server, run this command:

sudo service redis-server start

Then, initialize a simple node-js application:

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

Then, proceed to create an Express.js server. Here is 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}`);
});

After creating the server, establish a Redis connection. 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.' });
  }
});

To implement the caching logic, we can 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. You can implement caching in advanced Node.js applications and achieve even better performance.

Caching Node.js Application With Memcached

Here is a guide on how to implement caching in a Node.js application using 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 the following 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'
    });
});

Besides, you can 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');
});

How to remove data from Memcached:

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

You can also implement error handling:

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

When you run node index.js, it will execute the caching operations you've set up. However, this is just a basic implementation. You can further optimize configurations depending on your application needs and implement monitoring for optimal application performance.

Conclusion

Caching in Node.js is an essential strategy for improving application performance and scalability. By understanding and implementing various caching patterns and strategies, you can 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.

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