Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

MERN Caching and Performance Evaluation

A practical seminar project that implements and evaluates multiple caching strategies in a sample MongoDB, Express, React, and Node.js application.

The project does not treat caching as a single Redis lookup. It compares caching across several architectural layers, implements cache invalidation, benchmarks different approaches with k6, and discusses when each strategy is appropriate.

This repository accompanies the seminar:

Implementing and Evaluating Caching Strategies for Web Applications in the MERN Stack

The seminar and implementation were completed by Nguyễn Phước Sang.


Project Motivation

In a typical MERN application, React sends requests to an Express API, the backend queries MongoDB through Mongoose, and the result is returned as JSON.

For read-heavy endpoints, many users may request the same data repeatedly. Querying MongoDB for every identical request wastes database and application resources.

Caching can reduce:

  • repeated database queries;
  • API response time;
  • backend CPU work;
  • pressure on the primary database;
  • unnecessary requests from the browser.

However, caching also introduces risks:

  • stale data;
  • incorrect cache keys;
  • invalidation bugs;
  • accidental caching of private data;
  • inconsistent behavior across application instances;
  • memory growth;
  • misleading benchmarks.

The project therefore focuses on both performance and correctness.


Main Objectives

  • create an uncached baseline API;
  • optimize MongoDB queries before adding cache;
  • implement browser and HTTP caching;
  • implement Redis cache-aside;
  • store object data using Redis Hash;
  • implement backend stale-while-revalidate;
  • use TanStack Query for client-side caching;
  • invalidate cached data after writes;
  • compare read and write caching patterns;
  • benchmark API behavior with k6;
  • evaluate latency, P95, throughput, and error rate;
  • document trade-offs and suitable use cases.

Technology Stack

Application

  • TypeScript
  • Node.js
  • Express.js
  • React
  • Vite
  • MongoDB
  • Mongoose
  • TanStack Query

Caching and Performance

  • Redis
  • Redis String
  • Redis Hash
  • HTTP Cache-Control
  • in-memory cache
  • stale-while-revalidate
  • k6

Architecture

flowchart LR
    Client[React Client] --> HTTP[Browser / HTTP Cache]
    HTTP --> API[Express API]
    Client --> Query[TanStack Query Cache]
    Query --> API

    API --> Memory[In-memory Cache]
    API --> Redis[(Redis)]
    API --> Mongo[(MongoDB)]

    Redis --> Mongo
Loading

MongoDB remains the source of truth. Redis is used as an acceleration layer rather than a replacement database.

When Redis does not contain a valid value, the backend must still be able to query MongoDB and return the response.


Demo Domain

The sample application uses public event data, such as:

  • title;
  • description;
  • city;
  • category;
  • start date;
  • publication status;
  • remaining tickets;
  • image URL.

Public event listings are suitable for caching because many users may read the same data, while small delays in freshness are often acceptable.

Transactional data such as payment state or temporary seat ownership should use stricter consistency rules and should not automatically reuse the same caching policy.


Implemented Caching Layers

1. MongoDB Query Optimization

Caching should not hide an inefficient database query.

The project first improves the source query through:

  • compound indexes;
  • filtered queries;
  • pagination;
  • limited response fields;
  • Mongoose .lean();
  • parallel independent operations with Promise.all;
  • explain("executionStats") when validating index usage.

This keeps cache misses reasonably fast.


2. HTTP Caching

The project configures HTTP caching for public responses and static files.

Important directives explored include:

  • public;
  • private;
  • no-store;
  • no-cache;
  • max-age;
  • s-maxage;
  • must-revalidate;
  • stale-while-revalidate;
  • immutable.

Hashed static assets can be cached for a long period because a new build generates new filenames.

index.html should usually have a shorter policy because it points to the latest asset files after deployment.

Private or authenticated responses must not be accidentally stored in a shared cache.


3. Client-Side Caching with TanStack Query

TanStack Query reduces repeated API requests during client navigation.

The implementation explores:

  • query keys;
  • staleTime;
  • gcTime;
  • refetch behavior;
  • explicit invalidation after mutations.

Client caching improves the experience for one browser session, but it does not reduce database work across different users unless the backend also uses an appropriate cache.


4. In-Memory Cache

A local Node.js memory cache provides very fast access and simple implementation.

Its limitations are important:

  • each backend process has a separate cache;
  • data is lost when the process restarts;
  • it does not naturally work across horizontal instances;
  • memory must be bounded;
  • invalidation becomes harder in a distributed deployment.

It is suitable for small, non-critical, short-lived data.


5. Redis Cache-Aside

Cache-aside is the main read strategy in the project.

1. Receive request.
2. Build a deterministic cache key.
3. Check Redis.
4. On HIT, return the cached value.
5. On MISS, query MongoDB.
6. Store the result in Redis with a TTL.
7. Return the result.

Pseudo-code:

const cached = await redis.get(cacheKey);

if (cached) {
  return JSON.parse(cached);
}

const data = await Event.find(filter).lean();

await redis.set(cacheKey, JSON.stringify(data), {
  EX: CACHE_TTL_SECONDS,
});

return data;

Advantages:

  • straightforward to implement;
  • works well for read-heavy endpoints;
  • the database remains the source of truth;
  • failures can fall back to MongoDB.

Trade-offs:

  • the first request after expiration is slower;
  • multiple simultaneous misses may cause a cache stampede;
  • write operations must invalidate related keys;
  • cache-key design must include every request parameter that changes the response.

6. Redis Hash for Event Details

Redis Hash is used to store event-detail fields as field-value pairs.

Example conceptual representation:

event:{id}
  title              -> ...
  city               -> ...
  category           -> ...
  remainingTickets   -> ...

This enables field-oriented operations such as:

  • HSET;
  • HGET;
  • HGETALL;
  • HINCRBY.

Redis Hash can be useful when individual fields are updated independently, but it is not automatically superior to storing serialized JSON. The choice depends on access and mutation patterns.


7. Backend Stale-While-Revalidate

The backend SWR implementation distinguishes between:

  • fresh cache;
  • stale but temporarily acceptable cache;
  • expired or missing cache.

Conceptual flow:

Fresh:
    return cached data

Stale:
    return cached data immediately
    refresh cache in the background

Missing / too old:
    query MongoDB
    rebuild cache
    return fresh data

This can reduce latency around expiration, but the application must explicitly accept returning slightly stale data.

It is suitable for public listings and non-critical content—not for financial or strongly consistent transaction state.


Implemented Read and Write Patterns

Cache-Aside

The application manages both cache reads and database fallback.

Best fit:

  • read-heavy APIs;
  • query results;
  • public data;
  • data that tolerates a controlled TTL.

Read-Through

The controller calls a cache service, and the cache service loads data from MongoDB on a miss.

This centralizes cache logic but requires a clear abstraction between application logic and the cache layer.

Write-Through

The application writes the primary database and updates relevant cache entries during the same application operation.

Advantages:

  • detail cache is close to the latest value.

Trade-offs:

  • writes become more complex;
  • partial failure must be handled;
  • list caches may still need invalidation;
  • cache should not become the accidental source of truth.

Write-Around

The application writes MongoDB and removes or bypasses affected cache entries.

The next read rebuilds the cache.

This avoids filling cache with data that may not be read soon, but the first read after a write becomes a miss.

Write-Behind / Write-Back

The project demonstrates write-behind using a non-critical counter such as viewCount.

1. increment the value in Redis;
2. accumulate changes;
3. flush the value to MongoDB later.

This can improve write throughput, but it introduces failure and durability risks.

It should not be used casually for:

  • payments;
  • orders;
  • inventory ownership;
  • irreversible financial state.

A production design would require durable queues, retries, idempotency, monitoring, and reconciliation.


Cache Invalidation

Invalidation is a central part of the project.

When event data changes, related cache entries must be removed or updated.

Examples:

  • invalidate event detail after an update;
  • invalidate event-list keys that may contain the event;
  • invalidate remaining-ticket data after a mock purchase;
  • invalidate client queries after a successful mutation;
  • ensure Redis String and Redis Hash representations do not disagree.

A cache key should include all input values that affect the response.

Conceptual example:

events:list:city=HCM:category=technology:page=1:limit=10

A key that ignores city, category, pagination, visibility, or user scope can return incorrect data.


Authenticated and Private Cache Considerations

The project also investigates Redis usage for:

  • JWT blocklists;
  • sessions;
  • permissions and RBAC data.

These use cases are different from public API response caching.

Security requirements include:

  • never sharing one user's private response with another;
  • avoiding public cache directives for authenticated responses;
  • including user or tenant scope in keys when needed;
  • expiring revoked-token entries at the appropriate time;
  • treating Redis availability and persistence requirements deliberately.

Benchmarking with k6

k6 is used to compare response behavior under repeatable load.

Example command:

k6 run \
  -e MODE=baseline \
  -e VUS=30 \
  -e DURATION=60s \
  k6/events.benchmark.js

The seminar compares metrics such as:

  • average response time;
  • median response time;
  • P95 latency;
  • requests per second;
  • request failure rate;
  • cache headers and correctness checks.

Representative Result

One recorded experiment used 30 virtual users for 60 seconds.

Mode Average latency P95 latency
Baseline 22.43 ms 50.26 ms
Redis warm cache 5.18 ms 13.03 ms

In that local setup, the warm Redis path reduced both average and P95 latency substantially.

These values should be interpreted as results from one controlled local environment. They are not universal production guarantees.


Correctness Testing

The project does not evaluate cache only by latency.

It also checks:

  • MISS followed by HIT behavior;
  • response headers that identify cache mode;
  • invalidation after PUT;
  • ticket-count updates after mock purchase;
  • consistency between detail cache and Redis Hash;
  • SWR transition from fresh to stale and rebuild;
  • in-memory cache state;
  • authenticated cache isolation;
  • database fallback when cache is unavailable.

Performance without correctness would make the cache dangerous.


Running the Project

Requirements

  • Node.js
  • npm
  • MongoDB
  • Redis
  • k6

Install dependencies

npm install

Install dependencies inside the relevant frontend and backend directories if the repository separates them.

Environment configuration

Create the required .env files.

Typical backend values:

PORT=3000
MONGODB_URI=mongodb://127.0.0.1:27017/mern-cache
REDIS_URL=redis://127.0.0.1:6379

Do not commit real credentials.

Run Redis with Docker

docker run -d \
  --name mern-cache-redis \
  -p 6379:6379 \
  redis:7-alpine

Start the backend

npm run dev

Start the frontend

npm run dev

Consult the actual package.json files for repository-specific scripts.


Example Benchmark Modes

Depending on the script version, benchmark modes may include:

baseline
redis
redis-hash
swr
read-through
write-through
write-around
write-behind

Example:

k6 run \
  -e MODE=redis \
  -e VUS=30 \
  -e DURATION=60s \
  --summary-export results/redis-summary.json \
  k6/events.benchmark.js

Key Findings

  1. Caching improves repeated reads only when the access pattern is suitable.
  2. Database optimization should happen before relying on Redis.
  3. P95 is often more informative than the average alone.
  4. Warm-cache results should not be compared dishonestly with a cold baseline.
  5. Cache invalidation is part of the feature, not an optional cleanup step.
  6. Redis failure should not automatically make a public read endpoint unavailable.
  7. Browser, client, backend, and database caching solve different problems.
  8. Public and private data require different policies.
  9. Write-behind is powerful but unsafe without durability mechanisms.
  10. Benchmarks should report environment, concurrency, duration, data size, and limitations.

Limitations

  • Benchmarks were executed locally.
  • The dataset and traffic model are smaller than a production system.
  • A single k6 machine can become part of the bottleneck.
  • Redis Cluster, CDN, and distributed tracing were discussed as extensions rather than fully evaluated production infrastructure.
  • Results depend on hardware, process state, data volume, MongoDB indexes, Redis state, and network conditions.
  • The project demonstrates patterns; it does not claim that every strategy should be used simultaneously in one production system.

Future Improvements

  • run benchmarks in a controlled containerized environment;
  • add repeated runs and statistical summaries;
  • separate cold-cache and warm-cache experiments;
  • add cache stampede protection;
  • implement distributed locks or request coalescing;
  • add Redis memory-policy experiments;
  • monitor hit rate, evictions, memory usage, and database load;
  • evaluate CDN and edge caching;
  • test multiple backend instances;
  • add Redis Cluster or Sentinel experiments;
  • add OpenTelemetry traces;
  • use a durable queue for write-behind;
  • evaluate larger and more realistic datasets.

Academic Context

This project was developed as a seminar for the course Web System Development Techniques at the University of Information Technology, VNU-HCM.

The work includes:

  • theory;
  • implementation;
  • benchmark design;
  • experimental results;
  • invalidation tests;
  • engineering trade-off analysis;
  • recommendations for practical use.

Author

Nguyễn Phước Sang

University of Information Technology — VNU-HCM

About

Performance evaluation of Redis and HTTP caching strategies using MongoDB, Express, React, Node.js and k6.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages