Skip to main content
  • Amr Samir
    • Home
    • Blog
    • Projects
    • About
    • Skills
    • Experience
    • Hire
    • certification
  • Amr Samir
    • Made byAmr Samir
    • All Right Reserved (2026)

    • github
    • twitter
Caching and Performance in Production Systems

Caching and Performance in Production Systems

A practical full-stack developer guide to using caching, Redis, CDN, monitoring, and performance optimization techniques to build faster and more reliable production systems.

12 min • Backend Development • Apr 30, 2026

This article is about

NestJSNestJSNode.jsNode.jsGinGinBackend ArchitectureBackend ArchitectureJavaScriptJavaScriptTypeScriptTypeScriptGoGoApplication SecurityApplication Security

Caching and Performance in Production Systems

Server rack and infrastructure

Hi, my name is Amr Samir, a Full-Stack Web Developer, and in this blog I want to talk about something that looks simple from the outside but makes a huge difference in real production systems: caching and performance.

Maybe yes, caching sounds like one of those topics we all say we understand. Store something temporarily, return it faster next time, done. But maybe not — because in production, caching is not only about speed. It is about reducing pressure on your database, protecting your backend from traffic spikes, improving user experience, and keeping the system stable when the product starts to grow.

A fast system is not only a nice feature. It is part of reliability.

When users open a page, call an API, or refresh a dashboard, they do not care how complex the backend is. They care that it loads quickly and works every time. As developers, our job is to make that happen without burning the database on every request.

That is where caching and performance engineering come in.


Why Caching Matters

Caching means storing frequently used data somewhere faster than the original source.

Instead of asking the database the same question again and again, we keep the answer in a faster layer like memory, Redis, Memcached, a CDN, or even the browser cache.

For example, imagine an endpoint like this:

GET /api/products/featured

If thousands of users request the same featured products every minute, it does not make sense to hit the database every single time. The data probably does not change every second. We can cache it for a short time and serve most requests from memory.

That gives us three big wins:

  1. Faster response times.
  2. Less database load.
  3. Better scalability.

And honestly, this is one of the most practical improvements you can make in backend systems. You are not changing the product. You are not rewriting the whole architecture. You are just making repeated work cheaper.


The Basic Idea

A normal request without caching may look like this:

Here is the same idea in a simple Markdown-friendly flow:

Client -> API Server -> Database -> API Server -> Client

The API receives the request, asks the database for the data, then sends the response back to the client.

With caching, the request becomes smarter:

Client -> API Server -> Cache Check
                     |-> Cache HIT  -> Return Cached Data -> Client
                     |-> Cache MISS -> Query Database -> Save Result in Cache -> Client

The first request may still be slower because it needs to fetch from the database. But after that, repeated requests can be served much faster from the cache.

That is the main goal: avoid doing expensive work when the result is already available.


In-Memory Cache vs Distributed Cache

There are two common ways to think about caching in backend applications.

1. In-memory cache

This cache lives inside the application process itself.

For example, a Node.js service can keep a JavaScript object or an LRU cache in memory. A Go service can keep a map or use an in-memory cache package.

It is very fast because there is no network call. The application reads directly from memory.

But there is a problem: each app instance has its own cache.

If you run five backend servers behind a load balancer, each one has a separate in-memory cache. One instance may have the data, another may not. This can create inconsistent behavior and more cache misses.

In-memory cache is good for small, simple, local, or temporary data. But it is not always the best choice for shared production data.

2. Distributed cache

A distributed cache is shared across multiple application instances.

Popular examples include:

  • Redis
  • Memcached
  • KeyDB
  • Cloud-managed cache services

This setup is more production-friendly because all backend instances can read from the same cache layer.

A distributed cache makes the cache layer shared between all backend instances:

Client -> Load Balancer
          |-> API Instance 1 -> Redis Cache -> Database
          |-> API Instance 2 -> Redis Cache -> Database
          |-> API Instance 3 -> Redis Cache -> Database

All application instances can read from the same cache, which makes the system more consistent than keeping a separate cache inside every server.

The trade-off is complexity. Redis is another service to run, secure, monitor, scale, and recover. But in serious production systems, that complexity is usually worth it.


Cache-Aside Pattern

The most common caching pattern I use is cache-aside.

It works like this:

  1. The app checks the cache first.
  2. If the data exists, return it.
  3. If the data does not exist, query the database.
  4. Store the result in the cache.
  5. Return the result to the client.

Example in simple pseudo-code:

async function getUserProfile(userId) {
  const cacheKey = `user:${userId}:profile`;

  const cachedProfile = await redis.get(cacheKey);

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

  const profile = await database.users.findById(userId);

  await redis.set(cacheKey, JSON.stringify(profile), {
    EX: 300, // 5 minutes
  });

  return profile;
}

This pattern is simple, flexible, and easy to understand. The application controls when to read from cache and when to update it.

But there is one thing to be careful with: cache invalidation.


Cache Invalidation: The Hard Part

Caching is easy until the data changes.

Let us say we cache a user profile for five minutes. Then the user updates their name. What should happen?

If we do nothing, the API may keep returning the old name until the cache expires.

There are a few common solutions:

Use short TTLs

A TTL means “time to live.” It defines how long a cache entry stays valid.

For data that changes often, use a short TTL. For data that rarely changes, use a longer TTL.

Example:

Product categories: 1 hour
User profile: 5 minutes
Feature flags: 30 seconds
Static content: 1 day or more

Short TTLs reduce stale data, but they also increase database load because entries expire more often.

Delete cache when data changes

When updating data, delete or refresh the related cache key.

await database.users.update(userId, payload);
await redis.del(`user:${userId}:profile`);

This is usually more accurate than waiting for TTL.

Version your cache keys

For some systems, cache keys can include versions.

products:v1:featured
products:v2:featured

This is helpful when the structure of cached data changes or when you want to invalidate a whole group of entries.


Common Cache Problems

Caching improves performance, but it can also create new problems if we are not careful.

Performance dashboard

Cache stampede

A cache stampede happens when many requests try to rebuild the same expired cache value at the same time.

Imagine one hot key expires. Suddenly, hundreds or thousands of requests miss the cache and hit the database together.

That can overload the database quickly.

Ways to reduce this:

  • Add random jitter to TTL values.
  • Use locks so only one request rebuilds the cache.
  • Refresh hot keys before they expire.
  • Use stale-while-revalidate when possible.

Cache penetration

Cache penetration happens when users request data that does not exist.

For example:

GET /api/products/unknown-id

If the product does not exist and we do not cache that result, every request goes directly to the database.

A common fix is to cache empty or null results for a short time.

if (!product) {
  await redis.set(cacheKey, JSON.stringify(null), { EX: 60 });
  return null;
}

This protects the database from repeated requests for missing data.

Hot key problem

A hot key is one cache key that receives a huge amount of traffic.

For example:

homepage:trending-products

If that key expires suddenly, the database can get hammered.

To handle this:

  • Use longer TTL for hot keys.
  • Refresh them in the background.
  • Pre-warm them before high traffic.
  • Split large hot keys when possible.

Cache outage

This is the one people sometimes forget.

What happens if Redis goes down?

If every request suddenly falls back to the database, the database may collapse too. This turns a cache issue into a full system outage.

Your app should handle cache failure gracefully:

  • Set timeouts on cache calls.
  • Use circuit breakers if needed.
  • Fall back carefully.
  • Avoid sending all traffic to the database at once.
  • Monitor cache health.

A cache should make the system faster, not become a single point of failure.


CDN and Browser Caching

Caching is not only a backend thing.

For static assets like images, CSS, JavaScript files, fonts, and videos, a CDN can make a huge difference.

A CDN stores content near users around the world. Instead of every user downloading assets from your main server, they get them from a nearby edge location.

This improves:

  • Page load time.
  • Global latency.
  • Server load.
  • Availability during traffic spikes.

For frontend applications, good caching headers are also important.

Example:

Cache-Control: public, max-age=31536000, immutable

This is useful for versioned static assets like:

app.8f3a1c.js
styles.91ab2.css

For API responses, you need to be more careful. Not every response should be cached by browsers or proxies, especially if it contains private user data.


Database Performance Still Matters

Caching is powerful, but it should not hide a bad database design forever.

If a query is slow, cache can reduce how often it runs, but the query is still slow when the cache misses.

Good backend performance also needs:

  • Proper indexes.
  • Optimized SQL queries.
  • Connection pooling.
  • Avoiding N+1 queries.
  • Pagination for large datasets.
  • Background jobs for heavy work.
  • Read replicas when needed.
  • Clear data access patterns.

If every request depends on one expensive query, caching may help for a while, but the root problem is still there.

I prefer to fix the slow path first, then use caching to reduce repeated work.


Compression and Payload Size

Performance is not only about databases and caches. Network payload size matters too.

If your API sends a huge JSON response when the client only needs five fields, that is wasted time and bandwidth.

Ways to improve this:

  • Enable Gzip or Brotli compression.
  • Return only the fields the frontend needs.
  • Paginate large lists.
  • Avoid deeply nested responses when not necessary.
  • Compress static assets.
  • Minify frontend bundles.
  • Lazy-load heavy resources.

Small improvements here add up, especially for users on slower networks.


Monitoring: You Cannot Optimize Blindly

Monitoring and analytics

One mistake I see a lot is guessing.

People say, “Redis will make it faster,” but they do not measure before or after.

You need metrics.

At minimum, track:

  • Cache hit rate.
  • Cache miss rate.
  • Response time.
  • P95 and P99 latency.
  • Database query count.
  • Slow queries.
  • Redis latency.
  • Error rate.
  • CPU and memory usage.
  • Request throughput.

A good cache hit rate tells you the cache is actually helping. A bad hit rate may mean your TTL is too short, your keys are too specific, or you are caching the wrong thing.

For observability, tools like Prometheus, Grafana, Datadog, New Relic, Elastic APM, and OpenTelemetry can be very useful.

The goal is simple: know what is happening before users complain.


Security Considerations

Performance should never come at the cost of security.

A cache can accidentally leak sensitive data if you are careless.

Here are rules I try to follow:

Do not expose Redis publicly

Redis and Memcached should live inside a private network. They should not be open to the public internet.

Use authentication and access control

Enable Redis authentication, ACLs, and strong passwords where possible.

Encrypt traffic when needed

If cache traffic moves across networks or cloud boundaries, use TLS.

Avoid caching sensitive data in plain text

Be careful with:

  • Access tokens.
  • Personal user data.
  • Payment data.
  • Private messages.
  • Session information.

If sensitive data must be cached, keep TTLs short and consider encryption.

Separate tenant data correctly

In multi-tenant systems, cache keys should include tenant IDs or account IDs.

Bad key:

settings

Better key:

tenant:123:settings

This avoids accidentally returning one tenant’s cached data to another tenant.


Example: Simple Redis Cache in Node.js

import { createClient } from 'redis';

const redis = createClient({
  url: process.env.REDIS_URL,
});

await redis.connect();

export async function getCachedData(key, fetcher, ttlSeconds = 300) {
  const cached = await redis.get(key);

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

  const data = await fetcher();

  await redis.set(key, JSON.stringify(data), {
    EX: ttlSeconds,
  });

  return data;
}

Usage:

const products = await getCachedData(
  'products:featured',
  () => productRepository.getFeaturedProducts(),
  300
);

This is not a complete production wrapper, but it shows the idea clearly.

In production, I would add:

  • Error handling.
  • Timeout handling.
  • Logging.
  • Metrics.
  • Cache bypass options.
  • Safe handling for null values.

Example: NestJS Cache Concept

In NestJS, you can use cache modules or Redis integrations depending on your stack.

A simple conceptual example:

@Injectable()
export class ProductService {
  constructor(
    private readonly cacheManager: Cache,
    private readonly productRepository: ProductRepository,
  ) {}

  async getFeaturedProducts() {
    const cacheKey = 'products:featured';

    const cached = await this.cacheManager.get(cacheKey);

    if (cached) {
      return cached;
    }

    const products = await this.productRepository.getFeatured();

    await this.cacheManager.set(cacheKey, products, 300);

    return products;
  }
}

The same idea applies: check cache, fetch from source if missing, then save the result.


Example: Go Cache-Aside Concept

func GetFeaturedProducts(ctx context.Context, redisClient *redis.Client, repo ProductRepository) ([]Product, error) {
    cacheKey := "products:featured"

    cached, err := redisClient.Get(ctx, cacheKey).Result()
    if err == nil {
        var products []Product
        if json.Unmarshal([]byte(cached), &products) == nil {
            return products, nil
        }
    }

    products, err := repo.GetFeatured(ctx)
    if err != nil {
        return nil, err
    }

    encoded, _ := json.Marshal(products)

    redisClient.Set(ctx, cacheKey, encoded, 5*time.Minute)

    return products, nil
}

Again, this is simplified. Real production code should handle Redis errors carefully so a cache issue does not break the main feature.


Practical Roadmap for Better Performance

If I were improving performance in a production system, I would not start randomly. I would follow a clear path.

1. Measure first

Before optimizing, check current metrics.

Find:

  • Slow endpoints.
  • High database usage.
  • Expensive queries.
  • Large responses.
  • Repeated requests.
  • High traffic pages.

2. Add caching where it makes sense

Start with safe, high-impact data:

  • Public content.
  • Product categories.
  • Feature flags.
  • Static configuration.
  • Trending items.
  • Dashboard summaries.
  • Expensive read-only queries.

3. Set good TTLs

Avoid one TTL for everything.

Different data needs different expiration rules.

4. Protect hot keys

Pre-warm them, refresh them early, or keep them alive longer.

5. Add CDN caching for static assets

This is usually an easy win for frontend performance.

6. Optimize database queries

Add indexes, fix slow queries, and avoid N+1 problems.

7. Compress responses

Enable Brotli or Gzip and avoid sending unnecessary data.

8. Monitor continuously

Track whether the changes are actually helping.

9. Test under load

Use tools like k6, Artillery, JMeter, or Locust to simulate real traffic.

10. Document your caching strategy

Your team should know what is cached, for how long, and how to invalidate it.


Final Thoughts

Caching is not magic, but it is one of the strongest tools we have for building fast production systems.

A good cache layer can reduce database pressure, improve response times, and help your app handle more traffic. But a bad cache strategy can create stale data, hidden bugs, security risks, and even outages.

So the goal is not “cache everything.”

The goal is to cache the right data, for the right amount of time, with the right monitoring and fallback behavior.

As a full-stack developer, I see caching as part of building a better user experience. Users may never know Redis is there, or that a CDN served the image, or that a query was avoided. They will only feel that the application is fast, smooth, and reliable.

And that is the point.

Table of Contents

  • Why Caching Matters
  • The Basic Idea
  • In-Memory Cache vs Distributed Cache
  • 1. In-memory cache
  • 2. Distributed cache
  • Cache-Aside Pattern
  • Cache Invalidation: The Hard Part
  • Use short TTLs
  • Delete cache when data changes
  • Version your cache keys
  • Common Cache Problems
  • Cache stampede
  • Cache penetration
  • Hot key problem
  • Cache outage
  • CDN and Browser Caching
  • Database Performance Still Matters
  • Compression and Payload Size
  • Monitoring: You Cannot Optimize Blindly
  • Security Considerations
  • Do not expose Redis publicly
  • Use authentication and access control
  • Encrypt traffic when needed
  • Avoid caching sensitive data in plain text
  • Separate tenant data correctly
  • Example: Simple Redis Cache in Node.js
  • Example: NestJS Cache Concept
  • Example: Go Cache-Aside Concept
  • Practical Roadmap for Better Performance
  • 1. Measure first
  • 2. Add caching where it makes sense
  • 3. Set good TTLs
  • 4. Protect hot keys
  • 5. Add CDN caching for static assets
  • 6. Optimize database queries
  • 7. Compress responses
  • 8. Monitor continuously
  • 9. Test under load
  • 10. Document your caching strategy
  • Final Thoughts

Recommended Posts

Building Resilient APIs: Retries, Circuit Breakers, and Rate Limiting
06 January 202617 min

Building Resilient APIs: Retries, Circuit Breakers, and Rate Limiting

A practical full-stack developer guide to building APIs that survive production failures using timeouts, retries, circuit breakers, and rate limiting, with examples in Node.js, NestJS, and Go.

Backend Development
Node.jsNode.jsNestJSNestJSGinGinBackend ArchitectureBackend Architecture
Read More
React2Shell: How I Would Handle a Critical RSC Vulnerability as a Full-Stack Developer
30 April 202610 min

React2Shell: How I Would Handle a Critical RSC Vulnerability as a Full-Stack Developer

A practical Full-Stack developer article about React2Shell, the React Server Components vulnerability wave, and how I would detect, patch, verify, rotate secrets, and harden a real Next.js production app.

Node.jsNode.jsBackend ArchitectureBackend ArchitectureReactReactNext.jsNext.js
Read More

Related Projects

E-techPay
E-techPay
  • PostgreSQLPostgreSQL
  • NestJSNestJS
  • ReactReact

E-techPay is a complete e-commerce platform that makes online shopping easy and secure. It's fast, reliable, and designed to provide the best shopping experience.

Check Project
here is arabic text
here is arabic text
  • PrismaPrisma
  • Node.jsNode.js
  • Next.jsNext.js

desc

Check Project

Related Certificates

Backend Development using ASP.Net

Backend Development using ASP.Net

Board Infinity

Jan 2025

View details
JavaScript: The Complete JavaScript Course 2024: From Zero to Expert!

JavaScript: The Complete JavaScript Course 2024: From Zero to Expert!

Udemy

Mar 2024

View details
Work with me