DEV Community

Cover image for How to Build a High-Performance Redis-Powered Node.js Application: A 2025 Step-by-Step Guide
FredAbod
FredAbod

Posted on • Edited on

3 1 1 1

How to Build a High-Performance Redis-Powered Node.js Application: A 2025 Step-by-Step Guide

Building a Redis-Powered Node.js Application: A Step-by-Step Guide

Redis is like that one friend who always seems to remember everything instantly. It's an in-memory data store that can significantly speed up your applications by reducing the need to repeatedly query your database. In this article, we'll build a Node.js Express application with Redis caching from scratch, explaining each step along the way.

Prerequisites

  • Node.js and npm installed
  • Basic understanding of Express.js
  • A sense of humor (optional, but recommended)

Let's Get Right Into It

Get It

Installing Redis on Windows

Since Redis wasn't built with Windows in mind (they're not exactly on speaking terms), we need to use Windows Subsystem for Linux (WSL).

Step 1: Install WSL

Open PowerShell with administrator privileges and run:

wsl --install
Enter fullscreen mode Exit fullscreen mode

This command installs WSL with Ubuntu. You might need to restart your computer, so save any cat videos you're watching for later.

Step 2: Set Up Ubuntu

After restart, a terminal will open asking you to create a username and password. Choose something memorable, unlike that "secure" password with 17 special characters you created last week and already forgot.

Step 3: Install Redis on Ubuntu

In your WSL terminal:

sudo apt-get update
sudo apt-get install redis-server
Enter fullscreen mode Exit fullscreen mode

Step 4: Start Redis Server

sudo service redis-server start
Enter fullscreen mode Exit fullscreen mode

To verify Redis is working:

redis-cli ping
Enter fullscreen mode Exit fullscreen mode

If it responds with "PONG," congratulations! Redis is running. If not, well, we've all been there. Try restarting the service or check for error messages.

Building Our Application Step by Step

Step 1: Initialize Your Node.js Project

Create a new directory and initialize a Node.js project:

mkdir redis-test
cd redis-test
npm init -y
Enter fullscreen mode Exit fullscreen mode

Step 2: Install Required Dependencies

npm install express mongoose dotenv redis
Enter fullscreen mode Exit fullscreen mode

Step 3: Create Environment Variables

Create a .env file:

PORT=4000
MONGODB_URI=mongodb://localhost:27017/students-db
REDIS_URL=redis://localhost:6379
Enter fullscreen mode Exit fullscreen mode

Step 4: Set Up Redis Connection

Create a directory for configuration files:

mkdir config
Enter fullscreen mode Exit fullscreen mode

Now let's create the Redis connection file. This file will handle connecting to Redis and provide fallback functionality if Redis is unavailable.

Create config/redis.js:

const redis = require('redis');

// Create a Redis client with connection to Redis server
const createRedisClient = async () => {
  try {
    // Create Redis client
    const client = redis.createClient({
      url: process.env.REDIS_URL || 'redis://localhost:6379'
    });

    // Setup event handlers
    client.on('error', (err) => {
      console.error('Redis Error:', err);
    });

    client.on('connect', () => {
      console.log('Redis connected');
    });

    client.on('reconnecting', () => {
      console.log('Redis reconnecting...');
    });

    client.on('end', () => {
      console.log('Redis connection closed');
    });

    // Connect to Redis
    await client.connect();

    return client;
  } catch (err) {
    console.error('Failed to create Redis client:', err);

    // Return a mock client that stores data in memory as fallback
    console.log('Using in-memory fallback for Redis');

    const mockStorage = {};

    return {
      get: async (key) => mockStorage[key] || null,
      set: async (key, value, options) => {
        mockStorage[key] = value;
        // Handle expiration if EX option provided
        if (options && options.EX) {
          setTimeout(() => {
            delete mockStorage[key];
          }, options.EX * 1000);
        }
        return 'OK';
      },
      del: async (key) => {
        if (mockStorage[key]) {
          delete mockStorage[key];
          return 1;
        }
        return 0;
      },
      keys: async (pattern) => {
        const regex = new RegExp('^' + pattern.replace('*', '.*') + '$');
        return Object.keys(mockStorage).filter(key => regex.test(key));
      },
      // Add other Redis commands as needed for your application
      hSet: async () => 'OK',
      hGetAll: async () => ({}),
      zAdd: async () => 1,
      zRange: async () => [],
      zRem: async () => 1,
      exists: async () => 0
    };
  }
};

module.exports = { createRedisClient };
Enter fullscreen mode Exit fullscreen mode

This setup gives us a robust Redis client that:

  1. Connects to our Redis server
  2. Handles errors and reconnection attempts
  3. Provides a fallback in-memory implementation if Redis is unavailable

Step 5: Create Main Application File

Now, let's create our main application file, building it step by step:

Create app.js:

// Load environment variables
require('dotenv').config();

// Import required packages
const express = require('express');
const mongoose = require('mongoose');
const { createRedisClient } = require('./config/redis');

// Initialize express app
const app = express();
const port = process.env.PORT || 4000;

// Global Redis client
let redisClient;

// Connect to databases and start server
async function startServer() {
  try {
    // Connect to Redis
    redisClient = await createRedisClient();

    // Connect to MongoDB
    await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/students-db');
    console.log('MongoDB connected');

    // Start express server
    app.listen(port, () => {
      console.log(`Student CRUD API running on port ${port}`);
    });
  } catch (err) {
    console.error('Failed to initialize connections:', err);
    process.exit(1);
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Create Student Model

Add this code to your app.js:

// Student Schema
const studentSchema = new mongoose.Schema({
  name: {
    type: String,
    required: true,
    trim: true
  },
  email: {
    type: String,
    required: true,
    unique: true,
    trim: true,
    lowercase: true
  },
  grade: {
    type: String,
    required: true,
    trim: true
  },
  age: {
    type: Number,
    required: true,
    min: 5
  },
  subjects: [{
    type: String,
    trim: true
  }],
  createdAt: {
    type: Date,
    default: Date.now
  }
});

// Student Model
const Student = mongoose.model('Student', studentSchema);

// Middleware
app.use(express.json());
Enter fullscreen mode Exit fullscreen mode

Step 7: Create Caching Middleware

This is where the magic happens. Let's add our caching middleware to app.js:

// Cache middleware
const cacheData = (expireTime = 3600) => {
  return async (req, res, next) => {
    // Skip caching for non-GET requests
    if (req.method !== 'GET') {
      return next();
    }

    // Create a cache key based on the full URL
    const cacheKey = `students:${req.originalUrl}`;

    try {
      // Check if cache exists
      const cachedData = await redisClient.get(cacheKey);

      if (cachedData) {
        console.log(`Cache hit for ${cacheKey}`);
        return res.json(JSON.parse(cachedData));
      }

      console.log(`Cache miss for ${cacheKey}`);

      // If not in cache, continue but modify res.json
      res.originalJson = res.json;
      res.json = function(data) {
        // Store in cache before sending response
        redisClient.set(cacheKey, JSON.stringify(data), { EX: expireTime })
          .catch(err => console.error('Redis cache error:', err));

        // Continue with the original response
        return res.originalJson(data);
      };

      next();
    } catch (err) {
      console.error('Cache middleware error:', err);
      next();
    }
  };
};
Enter fullscreen mode Exit fullscreen mode

Let's break down what this middleware does:

  1. It only applies to GET requests because we don't typically want to cache modifications
  2. It creates a unique cache key based on the URL
  3. It checks if the data for that URL is already in Redis
    • If found, it returns the cached data immediately (cache hit)
    • If not found, it modifies the response to save the data to Redis before sending it (cache miss)

Step 8: Create Cache Invalidation Function

Next, we need a way to clear the cache when data changes:

// Clear cache helper
const clearCache = async (pattern) => {
  try {
    const keys = await redisClient.keys(pattern);
    if (keys.length > 0) {
      console.log(`Clearing cache keys matching: ${pattern}`);
      await Promise.all(keys.map(key => redisClient.del(key)));
    }
  } catch (err) {
    console.error('Error clearing cache:', err);
  }
};
Enter fullscreen mode Exit fullscreen mode

This function finds all Redis keys matching a pattern (like students:/api/students*) and deletes them, ensuring that when data changes, cached versions are cleared.

Step 9: Create API Routes with Caching

Now let's implement our API routes with Redis caching:

// Routes with caching
// 1. Get all students
app.get('/api/students', cacheData(60), async (req, res) => {
  try {
    const students = await Student.find({});
    res.json(students);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// 2. Get student by ID
app.get('/api/students/:id', cacheData(60), async (req, res) => {
  try {
    const student = await Student.findById(req.params.id);
    if (!student) {
      return res.status(404).json({ message: 'Student not found' });
    }
    res.json(student);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});
Enter fullscreen mode Exit fullscreen mode

Notice how we're applying the cacheData(60) middleware to our GET routes? This means the data will be cached for 60 seconds. After that, a fresh copy will be retrieved from MongoDB.

Step 10: Create Routes That Invalidate Cache

When data changes, we need to invalidate our cache:

// 3. Create student
app.post('/api/students', async (req, res) => {
  try {
    const student = new Student(req.body);
    await student.save();

    // Clear the list cache when a new student is added
    await clearCache('students:/api/students*');

    res.status(201).json(student);
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

// 4. Update student
app.put('/api/students/:id', async (req, res) => {
  try {
    const student = await Student.findByIdAndUpdate(
      req.params.id,
      req.body,
      { new: true, runValidators: true }
    );

    if (!student) {
      return res.status(404).json({ message: 'Student not found' });
    }

    // Clear both specific and list caches
    await Promise.all([
      clearCache(`students:/api/students/${req.params.id}`),
      clearCache('students:/api/students*')
    ]);

    res.json(student);
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

// 5. Delete student
app.delete('/api/students/:id', async (req, res) => {
  try {
    const student = await Student.findByIdAndDelete(req.params.id);

    if (!student) {
      return res.status(404).json({ message: 'Student not found' });
    }

    // Clear both specific and list caches
    await Promise.all([
      clearCache(`students:/api/students/${req.params.id}`),
      clearCache('students:/api/students*')
    ]);

    res.json({ message: 'Student deleted successfully' });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});
Enter fullscreen mode Exit fullscreen mode

In each of these routes, we call clearCache() after modifying data to ensure users always see the most up-to-date information.

Step 11: Add More Advanced Routes

Let's add some more routes to show how we can cache filtered data:

// 6. Search students by name or email (with caching)
app.get('/api/students/search/:query', cacheData(30), async (req, res) => {
  try {
    const query = req.params.query;
    const students = await Student.find({
      $or: [
        { name: { $regex: query, $options: 'i' } },
        { email: { $regex: query, $options: 'i' } }
      ]
    });

    res.json(students);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// 7. Get students by grade (with caching)
app.get('/api/students/grade/:grade', cacheData(60), async (req, res) => {
  try {
    const students = await Student.find({ grade: req.params.grade });
    res.json(students);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});
Enter fullscreen mode Exit fullscreen mode

We're using a shorter cache duration (30 seconds) for search results since they might change more frequently.

Step 12: Add Error Handler and Start the Server

Finally, let's add an error handler and start the server:

// Error handler
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Something went wrong!' });
});

// Start the server
startServer();

module.exports = app;
Enter fullscreen mode Exit fullscreen mode

Testing the Application

Now that we've built our application, let's test its caching capabilities:

  1. Start your server:
   node app.js
Enter fullscreen mode Exit fullscreen mode
  1. Create a new student:
   curl -X POST http://localhost:4000/api/students -H "Content-Type: application/json" -d '{"name":"John Doe","email":"john@example.com","grade":"A","age":15,"subjects":["Math","Science"]}'
Enter fullscreen mode Exit fullscreen mode
  1. Get all students (first request - cache miss):
   curl http://localhost:4000/api/students
Enter fullscreen mode Exit fullscreen mode

You should see "Cache miss" in your console.

  1. Get all students again (second request - cache hit):
   curl http://localhost:4000/api/students
Enter fullscreen mode Exit fullscreen mode

You should see "Cache hit" in your console and notice the response comes back much faster!

  1. Update a student, then get all students again to see cache invalidation in action:
   curl -X PUT http://localhost:4000/api/students/[student-id] -H "Content-Type: application/json" -d '{"name":"John Smith"}'
   curl http://localhost:4000/api/students
Enter fullscreen mode Exit fullscreen mode

You should see "Cache miss" again since the cache was cleared.

The Benefits of Redis Caching

  1. Blistering Speed: Redis operations happen in microseconds, while database queries can take milliseconds or more. That might not seem like much, but it adds up when you have thousands of users.

  2. Reduced Database Load: Your database server can focus on important write operations instead of handling the same read requests over and over.

  3. Scalable Architecture: By implementing Redis, you're already preparing your application for growth. As traffic increases, your caching layer will help maintain performance.

  4. Improved User Experience: Faster response times lead to happier users. Nobody likes waiting for a website to load.

Common Redis Caching Pitfalls to Avoid

  1. Caching Everything: Some data changes too frequently or is too personalized to benefit from caching.

  2. Incorrect Cache Invalidation: Failing to clear the cache when data changes leads to stale data being served to users.

  3. Using Too Long Expiration Times: Find the right balance between cache hits and data freshness.

  4. Not Handling Redis Failures: Always have a fallback plan, like our in-memory cache replacement.

Conclusion

We've just built a complete Node.js application with Redis caching! By strategically implementing caching for our read operations and carefully invalidating the cache when data changes, we've created a system that can deliver fast responses while maintaining data accuracy.

Remember that caching isn't a silver bullet - it's a tool that, when used properly, can significantly improve your application's performance. Start with conservative cache durations and adjust based on your specific use case and data volatility.

As the wise computer science saying goes: "There are only two hard things in Computer Science: cache invalidation and naming things." Now you're well equipped to handle at least one of them!

Happy coding!

Happy Coding

Complete Code

For reference, here's the complete app.js file we built throughout this tutorial:

require('dotenv').config();
const express = require('express');
const mongoose = require('mongoose');
const { createRedisClient } = require('./config/redis');

// Initialize express app
const app = express();
const port = process.env.PORT || 4000;

// Global Redis client
let redisClient;

// Connect to databases and start server
async function startServer() {
  try {
    // Connect to Redis
    redisClient = await createRedisClient();

    // Connect to MongoDB
    await mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/students-db');
    console.log('MongoDB connected');

    // Start express server
    app.listen(port, () => {
      console.log(`Student CRUD API running on port ${port}`);
    });
  } catch (err) {
    console.error('Failed to initialize connections:', err);
    process.exit(1);
  }
}

// Student Schema
const studentSchema = new mongoose.Schema({
  name: {
    type: String,
    required: true,
    trim: true
  },
  email: {
    type: String,
    required: true,
    unique: true,
    trim: true,
    lowercase: true
  },
  grade: {
    type: String,
    required: true,
    trim: true
  },
  age: {
    type: Number,
    required: true,
    min: 5
  },
  subjects: [{
    type: String,
    trim: true
  }],
  createdAt: {
    type: Date,
    default: Date.now
  }
});

// Student Model
const Student = mongoose.model('Student', studentSchema);

// Middleware
app.use(express.json());

// Cache middleware
const cacheData = (expireTime = 3600) => {
  return async (req, res, next) => {
    // Skip caching for non-GET requests
    if (req.method !== 'GET') {
      return next();
    }

    // Create a cache key based on the full URL
    const cacheKey = `students:${req.originalUrl}`;

    try {
      // Check if cache exists
      const cachedData = await redisClient.get(cacheKey);

      if (cachedData) {
        console.log(`Cache hit for ${cacheKey}`);
        return res.json(JSON.parse(cachedData));
      }

      console.log(`Cache miss for ${cacheKey}`);

      // If not in cache, continue but modify res.json
      res.originalJson = res.json;
      res.json = function(data) {
        // Store in cache before sending response
        redisClient.set(cacheKey, JSON.stringify(data), { EX: expireTime })
          .catch(err => console.error('Redis cache error:', err));

        // Continue with the original response
        return res.originalJson(data);
      };

      next();
    } catch (err) {
      console.error('Cache middleware error:', err);
      next();
    }
  };
};

// Clear cache helper
const clearCache = async (pattern) => {
  try {
    const keys = await redisClient.keys(pattern);
    if (keys.length > 0) {
      console.log(`Clearing cache keys matching: ${pattern}`);
      await Promise.all(keys.map(key => redisClient.del(key)));
    }
  } catch (err) {
    console.error('Error clearing cache:', err);
  }
};

// Routes with caching
// 1. Get all students
app.get('/api/students', cacheData(60), async (req, res) => {
  try {
    const students = await Student.find({});
    res.json(students);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// 2. Get student by ID
app.get('/api/students/:id', cacheData(60), async (req, res) => {
  try {
    const student = await Student.findById(req.params.id);
    if (!student) {
      return res.status(404).json({ message: 'Student not found' });
    }
    res.json(student);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// 3. Create student
app.post('/api/students', async (req, res) => {
  try {
    const student = new Student(req.body);
    await student.save();

    // Clear the list cache when a new student is added
    await clearCache('students:/api/students*');

    res.status(201).json(student);
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

// 4. Update student
app.put('/api/students/:id', async (req, res) => {
  try {
    const student = await Student.findByIdAndUpdate(
      req.params.id,
      req.body,
      { new: true, runValidators: true }
    );

    if (!student) {
      return res.status(404).json({ message: 'Student not found' });
    }

    // Clear both specific and list caches
    await Promise.all([
      clearCache(`students:/api/students/${req.params.id}`),
      clearCache('students:/api/students*')
    ]);

    res.json(student);
  } catch (err) {
    res.status(400).json({ error: err.message });
  }
});

// 5. Delete student
app.delete('/api/students/:id', async (req, res) => {
  try {
    const student = await Student.findByIdAndDelete(req.params.id);

    if (!student) {
      return res.status(404).json({ message: 'Student not found' });
    }

    // Clear both specific and list caches
    await Promise.all([
      clearCache(`students:/api/students/${req.params.id}`),
      clearCache('students:/api/students*')
    ]);

    res.json({ message: 'Student deleted successfully' });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// 6. Search students by name or email (with caching)
app.get('/api/students/search/:query', cacheData(30), async (req, res) => {
  try {
    const query = req.params.query;
    const students = await Student.find({
      $or: [
        { name: { $regex: query, $options: 'i' } },
        { email: { $regex: query, $options: 'i' } }
      ]
    });

    res.json(students);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// 7. Get students by grade (with caching)
app.get('/api/students/grade/:grade', cacheData(60), async (req, res) => {
  try {
    const students = await Student.find({ grade: req.params.grade });
    res.json(students);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// Error handler
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Something went wrong!' });
});

// Start the server
startServer();

module.exports = app;
Enter fullscreen mode Exit fullscreen mode

And the complete redis.js file:

const redis = require('redis');

// Create a Redis client with connection to Redis server
const createRedisClient = async () => {
  try {
    // Create Redis client
    const client = redis.createClient({
      url: process.env.REDIS_URL || 'redis://localhost:6379'
    });

    // Setup event handlers
    client.on('error', (err) => {
      console.error('Redis Error:', err);
    });

    client.on('connect', () => {
      console.log('Redis connected');
    });

    client.on('reconnecting', () => {
      console.log('Redis reconnecting...');
    });

    client.on('end', () => {
      console.log('Redis connection closed');
    });

    // Connect to Redis
    await client.connect();

    return client;
  } catch (err) {
    console.error('Failed to create Redis client:', err);

    // Return a mock client that stores data in memory as fallback
    console.log('Using in-memory fallback for Redis');

    const mockStorage = {};

    return {
      get: async (key) => mockStorage[key] || null,
      set: async (key, value, options) => {
        mockStorage[key] = value;
        // Handle expiration if EX option provided
        if (options && options.EX) {
          setTimeout(() => {
            delete mockStorage[key];
          }, options.EX * 1000);
        }
        return 'OK';
      },
      del: async (key) => {
        if (mockStorage[key]) {
          delete mockStorage[key];
          return 1;
        }
        return 0;
      },
      keys: async (pattern) => {
        const regex = new RegExp('^' + pattern.replace('*', '.*') + '$');
        return Object.keys(mockStorage).filter(key => regex.test(key));
      },
      // Add other Redis commands as needed for your application
      hSet: async () => 'OK',
      hGetAll: async () => ({}),
      zAdd: async () => 1,
      zRange: async () => [],
      zRem: async () => 1,
      exists: async () => 0
    };
  }
};

module.exports = { createRedisClient };
Enter fullscreen mode Exit fullscreen mode

Hostinger image

Get n8n VPS hosting 3x cheaper than a cloud solution

Get fast, easy, secure n8n VPS hosting from $4.99/mo at Hostinger. Automate any workflow using a pre-installed n8n application and no-code customization.

Start now

Top comments (0)

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay