Complete Cache Strategy Guide: Maximizing Performance with CDN, Redis, and API Optimization
Introduction
When improving the performance of web applications, optimizing your cache strategy is something you cannot avoid. By leveraging appropriate caching, you can speed up page loads, reduce server load, and improve user experience.
In this article, we explain how to maximize performance by making full use of various caching technologies such as CDN, Redis / Memcached, API response caching, Next.js ISR / SSG, and client-side caching. By managing cache properly, you can build a more scalable and efficient system.
Now, let’s look at specific strategies.
Optimized Delivery of Static Content with CDNs (Cloudflare / AWS CloudFront)
A CDN (Content Delivery Network) distributes static content (HTML, CSS, JavaScript, images, etc.) across edge servers around the world and delivers it from the edge server closest to the user. This shortens load time and reduces the load on the origin server.
How a CDN Works
A CDN operates as follows:
- When a user opens a website, the browser sends a request to the CDN’s edge server.
- If the edge server has the content cached, it serves the cached content as-is (cache hit).
- If the content is not cached (cache miss), the edge server fetches it from the origin server, stores it in the cache, and then serves it to the user.
- From then on, other users can also get the cached content from the edge server.
Main Benefits
- Reduced latency
- Because geographically distributed edge servers deliver content from a physically closer server, latency is reduced.
- Reduced server load
- Requests to the origin server are reduced, which lowers backend load and makes scaling easier.
- Improved security
- Provides security features such as DDoS protection, WAF (Web Application Firewall), and bot protection.
Comparison of Cloudflare and AWS CloudFront
| Item | Cloudflare | AWS CloudFront |
|---|---|---|
| Cache control | Flexible rule configuration (Cache Rules, Page Rules) | Easy integration with S3 and Lambda@Edge |
| DDoS protection | Provided for free as a standard feature | Integrated with AWS Shield (paid) |
| TLS/SSL | Automatically provides free SSL | Managed via ACM (AWS Certificate Manager) |
| Pricing | Free plan available | Pay-as-you-go |
Setting Up a Static Site with Cloudflare
- Register your domain with Cloudflare
Add the domain from the Cloudflare dashboard and change the DNS settings. - Configure cache settings
Use Cache Rules to configure cache policies.
# Cache-Control header settings (cache for static files)
location ~* \.(css|js|jpg|png|gif|ico|svg|woff|woff2|ttf|otf)$ {
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
Integrating AWS CloudFront with S3
Example: Configure CloudFront with S3 as the origin
- Create an S3 bucket and configure it as a static website
- Specify S3 as the origin in CloudFront
- Customize cache behavior
Create a Cache Policy and include specific request headers in the cache key.
{
"name": "MyCustomPolicy",
"minTTL": 60,
"maxTTL": 86400,
"defaultTTL": 3600,
"parametersInCacheKeyAndForwardedToOrigin": {
"headersConfig": {
"headerBehavior": "whitelist",
"headers": ["Authorization"]
}
}
}
Reducing Database Load with Redis / Memcached
To reduce load on the database, frequently accessed data is cached in memory. This is the basic role of Redis and Memcached.
For example, when the same query is executed frequently against the database, by caching the result you can:
- Greatly reduce database load
- Improve query response time
Differences Between Redis and Memcached
| Feature | Redis | Memcached |
|---|---|---|
| Data structures | Rich types such as lists, sets, hashes | Simple key-value |
| Persistence | Optional disk persistence | Memory only (no persistence) |
| Scalability | Master-slave configuration | Simple distributed configuration |
| Transactions | Supported (MULTI/EXEC) | Not supported |
| Memory management | Selectable policies such as LRU (Least Recently Used) | Automatic eviction (LRU only) |
| Use cases | Session management, rankings, queues | Simple data caching |
Redis: Multi-functional and supports persistence, so it can be used for a wide range of purposesMemcached: Specialized for simple key-value caching and very fast
Concrete Use Cases
Caching Frequently Queried Data (Rankings / Configuration Values)
By caching frequently accessed ranking data, you can reduce DB load.
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v4"
"github.com/redis/go-redis/v9"
)
type LeaderboardEntry struct {
ID int64 `json:"id"`
Name string `json:"name"`
Score float64 `json:"score"`
}
type App struct {
db *pgxpool.Pool
redis *redis.Client
}
func main() {
// ── env ───────────────────────────────────────────────
pgURL := envOr("DATABASE_URL", "postgres://user:password@localhost:5432/mydb")
redisAddr := envOr("REDIS_ADDR", "127.0.0.1:6379")
redisPass := os.Getenv("REDIS_PASSWORD")
cacheTTL := 10 * time.Minute
// ── Postgres ──────────────────────────────────────────
ctx := context.Background()
pool, err := pgxpool.New(ctx, pgURL)
if err != nil {
log.Fatalf("failed to create pgx pool: %v", err)
}
defer pool.Close()
// ── Redis ─────────────────────────────────────────────
rdb := redis.NewClient(&redis.Options{
Addr: redisAddr,
Password: redisPass,
DB: 0,
})
if err := rdb.Ping(ctx).Err(); err != nil {
log.Fatalf("failed to connect redis: %v", err)
}
defer func() { _ = rdb.Close() }()
app := &App{db: pool, redis: rdb}
// ── Echo ──────────────────────────────────────────────
e := echo.New()
e.GET("/leaderboard", app.GetLeaderboard(cacheTTL))
log.Println("listening on :8080")
if err := e.Start(":8080"); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}
func (a *App) GetLeaderboard(ttl time.Duration) echo.HandlerFunc {
return func(c echo.Context) error {
const cacheKey = "leaderboard"
ctx, cancel := context.WithTimeout(c.Request().Context(), 3*time.Second)
defer cancel()
// 1) try cache
if cached, err := a.redis.Get(ctx, cacheKey).Bytes(); err == nil && len(cached) > 0 {
var rows []LeaderboardEntry
if err := json.Unmarshal(cached, &rows); err == nil {
return c.JSON(http.StatusOK, rows)
}
}
// 2) query DB
qctx, qcancel := context.WithTimeout(ctx, 2*time.Second)
defer qcancel()
rows, err := a.db.Query(qctx, `
SELECT id, name, score
FROM leaderboard
ORDER BY score DESC
LIMIT 10`)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "db query failed")
}
defer rows.Close()
var list []LeaderboardEntry
for rows.Next() {
var e LeaderboardEntry
if err := rows.Scan(&e.ID, &e.Name, &e.Score); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "db scan failed")
}
list = append(list, e)
}
if err := rows.Err(); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "db rows error")
}
// 3) set cache (best-effort)
if b, err := json.Marshal(list); err == nil {
_ = a.redis.Set(ctx, cacheKey, b, ttl).Err()
}
return c.JSON(http.StatusOK, list)
}
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
Key points
- Cache ranking data in
Redisunder the keyleaderboard - Reduce queries to the DB and lower load
- Set a cache expiration (10 minutes) so stale data is automatically removed
Temporarily Storing Computationally Expensive Data
For example, by caching data analysis results or temporary aggregation results, you can avoid repeating the same expensive computation.
package main
import (
"encoding/json"
"log"
"math"
"net/http"
"os"
"strconv"
"time"
"github.com/bradfitz/gomemcache/memcache"
"github.com/labstack/echo/v4"
)
type Response struct {
Result int64 `json:"result"`
Cached bool `json:"cached"`
}
func main() {
// ── Settings ─────────────────────────────────────────
addr := envOr("MEMCACHED_ADDR", "127.0.0.1:11211")
cacheKey := "expensive_result"
ttl := int32(30) // seconds
// ── Clients ──────────────────────────────────────────
mc := memcache.New(addr)
// ── Echo ─────────────────────────────────────────────
e := echo.New()
e.GET("/compute", func(c echo.Context) error {
// 1) Try cache
if it, err := mc.Get(cacheKey); err == nil && it != nil && len(it.Value) > 0 {
if v, err := strconv.ParseInt(string(it.Value), 10, 64); err == nil {
return c.JSON(http.StatusOK, Response{Result: v, Cached: true})
}
}
// 2) Expensive computation
res := expensiveComputation()
// 3) Best-effort cache write
_ = mc.Set(&memcache.Item{
Key: cacheKey,
Value: []byte(strconv.FormatInt(res, 10)),
Expiration: ttl,
})
return c.JSON(http.StatusOK, Response{Result: res, Cached: false})
})
log.Println("listening on :8080")
if err := e.Start(":8080"); err != nil {
log.Fatal(err)
}
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
Key points
- Store the computation result in Memcached under the key
expensive_result - If the cache exists, return it and skip the computation
- Cache expires in 30 seconds, allowing retrieval of up-to-date results
Applying API Response Caching (Optimizing GraphQL / REST API)
By applying API response caching, you can reduce load on the origin server and improve response speed for clients. We explain effective cache strategies for both GraphQL and REST APIs, along with sample code.
Apollo Client’s Caching Features
When using Apollo Client on the frontend, it uses InMemoryCache by default to cache query results. This allows subsequent requests for the same data to be served from the local cache instead of the origin server.
import { ApolloClient, InMemoryCache, gql } from '@apollo/client';
// Apollo Client configuration
const client = new ApolloClient({
uri: 'https://example.com/graphql',
cache: new InMemoryCache(),
});
// Query that effectively uses cache
client.query({
query: gql`
query GetUser {
user(id: "1") {
id
name
}
}
`,
}).then(response => console.log(response.data));
Key points
- InMemoryCache caches query results, and when the same data is requested, the cache is used.
- The default cache-first policy means that if data exists in the cache, no network request is made.
Using a CDN for API Response Caching
By caching at edge servers (Cloudflare, Fastly, AWS CloudFront, etc.), you can reduce the number of requests forwarded from clients to the origin server.
Below is an example Cloudflare Workers configuration that applies caching to a GraphQL API.
addEventListener("fetch", event => {
event.respondWith(handleRequest(event.request));
});
async function handleRequest(request) {
const cacheUrl = new URL(request.url);
const cache = caches.default;
let response = await cache.match(cacheUrl);
if (!response) {
response = await fetch(request);
// Set cache (valid for 60 seconds)
response = new Response(response.body, response);
response.headers.append("Cache-Control", "s-maxage=60");
await cache.put(cacheUrl, response.clone());
}
return response;
}
- Set
s-maxage=60to cache at the CDN for 60 seconds. - Managing cache with
Cloudflare Workerscan greatly reduce load on the origin server.
Data Caching with Redis
You can apply caching per resolver using Redis. This is especially effective for frequently accessed data (e.g., rankings, news feeds).
Example: Implementing Redis cache with Apollo Server
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"time"
"github.com/graphql-go/graphql"
"github.com/graphql-go/handler"
"github.com/labstack/echo/v4"
"github.com/redis/go-redis/v9"
)
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
func main() {
// ── Redis ─────────────────────────────────────────────
rdb := redis.NewClient(&redis.Options{
Addr: envOr("REDIS_ADDR", "127.0.0.1:6379"),
Password: os.Getenv("REDIS_PASSWORD"),
DB: 0,
})
ctx := context.Background()
if err := rdb.Ping(ctx).Err(); err != nil {
log.Fatalf("redis connect failed: %v", err)
}
defer func() { _ = rdb.Close() }()
// ── GraphQL Schema ────────────────────────────────────
userType := graphql.NewObject(graphql.ObjectConfig{
Name: "User",
Fields: graphql.Fields{
"id": &graphql.Field{Type: graphql.NewNonNull(graphql.ID)},
"name": &graphql.Field{Type: graphql.NewNonNull(graphql.String)},
},
})
queryType := graphql.NewObject(graphql.ObjectConfig{
Name: "Query",
Fields: graphql.Fields{
"user": &graphql.Field{
Type: userType,
Args: graphql.FieldConfigArgument{
"id": &graphql.ArgumentConfig{Type: graphql.NewNonNull(graphql.ID)},
},
Resolve: func(p graphql.ResolveParams) (any, error) {
// Per-resolver cache (60 seconds)
id := p.Args["id"].(string)
cacheKey := "user:" + id
// 1) Get from cache
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
if b, err := rdb.Get(ctx, cacheKey).Bytes(); err == nil && len(b) > 0 {
var u User
if err := json.Unmarshal(b, &u); err == nil {
return u, nil
}
}
// 2) Instead of DB fetch (example)
u := User{ID: id, Name: "John Doe"}
// 3) Best-effort cache write
if bb, err := json.Marshal(u); err == nil {
_ = rdb.Set(context.Background(), cacheKey, bb, 60*time.Second).Err()
}
return u, nil
},
},
},
})
schema, err := graphql.NewSchema(graphql.SchemaConfig{Query: queryType})
if err != nil {
log.Fatalf("schema init error: %v", err)
}
h := handler.New(&handler.Config{
Schema: &schema,
Pretty: true,
GraphiQL: true, // You can open /graphql in a browser to test
})
// ── Echo ──────────────────────────────────────────────
e := echo.New()
e.Any("/graphql", echo.WrapHandler(h))
log.Println("listening on :8080")
if err := e.Start(":8080"); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
Key points
redis.Get→ on hit, decode JSON into User and return immediately (avoid DB)- On miss, fetch from DB (dummy in this sample), then
SetwithEX=60s - A 300ms timeout is used so Redis latency does not drag down the entire app
Using ETag / Last-Modified Headers
In REST APIs, using ETag and Last-Modified allows you to check for data changes on each request and reuse cache when there are no changes.
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"github.com/labstack/echo/v4"
)
type Data struct {
Message string `json:"message"`
}
func main() {
e := echo.New()
e.GET("/data", func(c echo.Context) error {
data := Data{Message: "Hello, World!"}
body, _ := json.Marshal(data)
etag := `"` + hash(body) + `"`
c.Response().Header().Set("ETag", etag)
if c.Request().Header.Get("If-None-Match") == etag {
return c.NoContent(http.StatusNotModified) // 304
}
return c.JSON(http.StatusOK, data)
})
e.Logger.Fatal(e.Start(":8080"))
}
func hash(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
Key points
- Convert response data to JSON → hash with SHA256 → return as ETag
- If the client’s
If-None-Matchmatches, return304 Not Modified - When data changes, the ETag changes, so new JSON is returned and unnecessary data transfer is reduced.
Browser Caching with Cache-Control: max-age
By letting the browser cache REST API responses, you can prevent unnecessary requests.
func main() {
e := echo.New()
e.GET("/data", func(c echo.Context) error {
c.Response().Header().Set("Cache-Control", "public, max-age=3600")
return c.JSON(http.StatusOK, map[string]string{
"message": "Hello, Cached World!",
})
})
e.Logger.Fatal(e.Start(":8080"))
}
Key points
max-age=3600lets the browser use its cache for 1 hour.
Improving Cache Strategy for User Sessions
By efficiently caching session information, you can reduce the load of authentication processing and improve application performance. Here we explain common session caching methods in detail and provide sample code for each.
Session Management with Redis (Server-Side Sessions)
Overview
- Store session information in Redis to speed up data retrieval during user authentication.
- Issue a unique session ID per user and store it in Redis.
- Enables scalable session management.
Advantages
- Can maintain per-user state (stateful).
- Since the server manages session information, there is no overhead of parsing tokens.
- Expired sessions can be automatically managed on the Redis side.
Disadvantages
- For load balancing, you need a mechanism to share session information (e.g., Redis cluster).
- Scalability is lower compared to JWT.
The following code builds a server using Echo that stores sessions in Redis.
package main
import (
"context"
"net/http"
"os"
"time"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"github.com/rbcervilla/redisstore/v9"
"github.com/redis/go-redis/v9"
"github.com/gorilla/sessions"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
func main() {
// ── Env (configure as needed) ────────────────────────────
redisAddr := getenv("REDIS_ADDR", "127.0.0.1:6379")
redisPass := os.Getenv("REDIS_PASSWORD")
sessionSecret := getenv("SESSION_SECRET", "replace-with-32bytes-secret")
sessionName := getenv("SESSION_NAME", "sid")
// ── Redis client ──────────────────────────────────────
rdb := redis.NewClient(&redis.Options{
Addr: redisAddr,
Password: redisPass,
DB: 0,
})
if err := rdb.Ping(context.Background()).Err(); err != nil {
panic(err)
}
defer rdb.Close()
// ── Redis-backed session store ────────────────────────
store, err := redisstore.NewRedisStore(context.Background(), rdb)
if err != nil {
panic(err)
}
defer store.Close()
// Signing/encryption keys (at least set a signing key)
// The second argument is the encryption key (optional). Pass about 32 bytes if you want encryption.
store.SetKeyPair([]byte(sessionSecret), nil)
// Cookie attributes
store.Options(sessions.Options{
Path: "/",
MaxAge: 60 * 60,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
// Redis key prefix (optional)
store.KeyPrefix("sess:")
// ── Echo ──────────────────────────────────────────────
e := echo.New()
e.Use(session.MiddlewareWithConfig(session.Config{
Skipper: nil,
}))
// /login: save to session
e.GET("/login", func(c echo.Context) error {
sess, _ := session.Get(sessionName, c)
sess.Values["user"] = &User{ID: 1, Name: "John Doe"}
if err := sess.Save(c.Request(), c.Response()); err != nil {
return c.String(http.StatusInternalServerError, "session save error")
}
return c.String(http.StatusOK, "Logged in")
})
// /profile: retrieve from session
e.GET("/profile", func(c echo.Context) error {
sess, _ := session.Get(sessionName, c)
val, ok := sess.Values["user"]
if !ok || val == nil {
return c.String(http.StatusUnauthorized, "Unauthorized")
}
user, _ := val.(*User)
return c.JSON(http.StatusOK, user)
})
e.Logger.Fatal(e.Start(":8080"))
}
func getenv(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
This code behaves as follows:
- When a user accesses
/login, session information is stored in Redis. - When a user accesses
/profile, authenticated user information is returned if session information exists. - Session information is stored in Redis instead of memory, making it easier to scale.
Session information is stored in Redis in the following format:
{
"sess:U2FtMWJhMWVh...": {
"cookie": {
"originalMaxAge": 3600000,
"expires": "2025-03-07T12:00:00.000Z",
"httpOnly": true,
"path": "/"
},
"user": {
"id": 1,
"name": "John Doe"
}
}
}
Stateless Authentication with JWT (JSON Web Token)
Overview
- The server does not hold sessions; instead, it issues a token to the client and authenticates each request.
- The token includes a signature to prevent tampering.
Advantages
- Highly scalable (suitable for serverless and multi-instance setups).
- No need for external storage like Redis.
- Since authentication information is carried with each request, response speed is fast.
Disadvantages
- Hard to invalidate tokens (if leaked, they remain valid until expiration).
- There is a cost to verifying the signature (JWT must be parsed each time).
The following code implements stateless authentication using Go + Echo + JSON Web Token (JWT).
package main
import (
"net/http"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
var secretKey = getenv("SECRET_KEY", "your_secret_key")
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
// JWT Claims
type JwtCustomClaims struct {
User User `json:"user"`
jwt.RegisteredClaims
}
func main() {
e := echo.New()
// To handle JSON bodies
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// --- /login: issue JWT ---
e.POST("/login", func(c echo.Context) error {
// Normally you would authenticate the user here
user := User{ID: 1, Name: "John Doe"}
// Create JWT claims
claims := &JwtCustomClaims{
User: user,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
// Create token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signedToken, err := token.SignedString([]byte(secretKey))
if err != nil {
return err
}
return c.JSON(http.StatusOK, echo.Map{
"token": signedToken,
})
})
// --- Group that requires JWT authentication ---
r := e.Group("/profile")
r.Use(middleware.JWTWithConfig(middleware.JWTConfig{
SigningKey: []byte(secretKey),
TokenLookup: "header:Authorization",
AuthScheme: "Bearer",
Claims: &JwtCustomClaims{},
}))
// /profile: verify JWT and return claims
r.GET("", func(c echo.Context) error {
user := c.Get("user").(*jwt.Token)
claims := user.Claims.(*JwtCustomClaims)
return c.JSON(http.StatusOK, claims.User)
})
e.Logger.Fatal(e.Start(":8080"))
}
func getenv(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
Key points
- When a user accesses
/login, a JWT is issued. - To access protected endpoints like
/profile, the client sends the JWT asAuthorization: Bearer <token>. - JWT is stateless on the server side, so it scales easily without using external storage like Redis.
Proper Configuration of Cache TTL (Time To Live)
TTL (Time To Live) is a key concept that controls the validity period of cached data. Setting it properly can improve performance and reduce costs. On the other hand, if TTL is not configured appropriately, it can lead to serving stale data and lower cache hit rates, so it is important to choose optimal values based on data characteristics.
How to Think About TTL Settings
When deciding on cache TTL, consider the following points:
- Data update frequency
- For frequently updated data, set a short TTL to keep data fresh.
- For data that rarely changes, set a long TTL to reduce unnecessary cache misses.
- Data importance
- Critical information such as authentication data or real-time data should have a short TTL.
- Immutable data such as static resources or historical data can have a long TTL.
- Balance between performance and cost
- Longer TTL improves cache hit rate and reduces load on APIs and DBs.
- However, if TTL is too long, there is a risk of holding stale data.
Recommended TTL by Data Type
| Data type | Recommended TTL | Reason |
|---|---|---|
| Static content (CSS, JS, images) | Several hours to several days | Rarely updated, so a longer TTL reduces load |
| User profile information | Several minutes to several hours | Changes are relatively infrequent, but some real-time aspect is needed |
| API responses (frequently updated) | Seconds to minutes | Needs to reflect the latest information, so shorter TTL |
| Authentication tokens (JWT, etc.) | Several hours to 1 day | Configured with a balance of security and convenience |
Methods for Dynamically Adjusting TTL
TTL can be set as a fixed value, but by adjusting it flexibly you can build a more appropriate cache strategy.
Three main methods:
- Sliding expiration
- Event-based cache invalidation
- Using CDN cache invalidation APIs
- Sliding expiration
- A method where TTL is extended each time there is access.
- When cached data is accessed, TTL is reset and the cache is kept until a certain time passes.
- Suitable for session management and temporary data.
Advantages
- Frequently used data remains in the cache
- For example, when caching session data for logged-in users, active users can keep their sessions cached.
- Unused cached data is removed
- Data that has not been accessed for a while is automatically removed, saving storage.
Disadvantages
- Cache management becomes more complex
- Since TTL is extended on each access, the logic is more complex than with a simple TTL.
- Certain data may remain in cache for a long time
- As long as access continues, the cache is not cleared, increasing the risk of stale data.
Example: User session management
Suppose you cache session information for logged-in users.
- As long as access continues, session information remains cached
- If there is no access for a certain period, the session is deleted
const cacheKey = `session_${userId}`;
const sessionTTL = 600; // 10 minutes
// 1. Get user session from cache
const sessionData = cache.get(cacheKey);
if (sessionData) {
// 2. Access occurred, so reset TTL (extend cache)
cache.set(cacheKey, sessionData, sessionTTL);
return sessionData;
}
// 3. If no session, create a new one
const newSession = createSession(userId);
cache.set(cacheKey, newSession, sessionTTL);
return newSession;
- This keeps the session while the user is actively accessing.
- If the user does not access for 10 minutes, the cache is deleted and re-login is required.
Example: API response caching
Consider caching search results.
const cacheKey = `search_results_${query}`;
const cacheTTL = 300; // 5 minutes
// Get from cache
const cachedResults = cache.get(cacheKey);
if (cachedResults) {
// If cache exists, extend TTL
cache.set(cacheKey, cachedResults, cacheTTL);
return cachedResults;
}
// If no cache, get data from API
const results = fetchSearchResults(query);
cache.set(cacheKey, results, cacheTTL);
return results;
- Search results are kept for 5 minutes.
- If the user performs the same search again after 3 minutes, TTL is extended by another 5 minutes.
- If there is no access for 5 minutes, the cache is deleted and new data is fetched on the next search.
- Event-based cache invalidation
- A method where related cache is deleted when data updates occur.
- By invalidating relevant cache when API or DB data is updated, you can always serve the latest data.
Normally, cache has a TTL (Time To Live) so it is automatically deleted after a certain time. However, if data changes, old data may remain until TTL expires.
For example:
- A user updates their profile but the cache is stale and not reflected
- Product stock changes but cached information is outdated and shows incorrect stock
- A notification system should show the latest notifications, but cache causes old notifications to appear
To solve these problems, introduce a mechanism that deletes (invalidates) related cache when data changes.
function updateUserProfile(userId, newProfileData) {
// 1. Update database
database.updateUserProfile(userId, newProfileData);
// 2. Since there was a change, delete cache
cache.delete(`user_profile_${userId}`);
}
Best practices for cache invalidation
- Delete only cache related to the changed data
- Deleting too much cache lowers the cache hit rate, so be careful.
- After deleting cache, immediately cache new data (prefetch)
- By fetching and caching new data right after deletion, you can reduce cache misses.
- Use tag-based cache management
- Example: delete all cache with tag category_123 to easily manage related data deletion.
- Using CDN cache invalidation APIs
- When using a CDN (Content Delivery Network), you can explicitly delete CDN cache to immediately serve the latest data.
- Cloudflare, AWS CloudFront, Fastly, etc. provide APIs to invalidate cache.
Example (using Cloudflare’s cache purge API)
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
-H "Authorization: Bearer {api_token}" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}'
Typical use cases include:
- Immediate updates of blog posts or news articles
- Updates to product information on e-commerce sites
- Updates to marketing campaign pages
Best Practices for TTL Settings
- Set a reasonable default TTL
- Set longer TTL for data that changes infrequently, and shorter TTL for dynamic data.
- Balance user experience and performance
- If cache is too short, data is fetched every time, increasing load.
- If cache is too long, you may serve stale data.
- Customize TTL per data type
- Instead of using a single TTL for all data, set appropriate values based on data characteristics.
- Properly design cache invalidation mechanisms
- Introduce mechanisms to explicitly delete cache when data changes.
Content Prefetching (Using Next.js ISR / SSG)
In Next.js, you can use Static Site Generation (SSG) and Incremental Static Regeneration (ISR) to optimize page rendering speed and reduce server load. This allows users to experience fast page loads while developers achieve efficient content delivery.
What is SSG (Static Site Generation)?
SSG is a mechanism where HTML is generated at build time and delivered from a CDN (Content Delivery Network). It is suitable for pages whose data does not change frequently and has the following benefits:
Characteristics of SSG
- Use getStaticProps to fetch data at build time (next build)
- Generated HTML is cached on the CDN and delivered quickly
- Low server load and high scalability
- When page content changes, redeployment is required
Use cases for SSG
- Blog posts (content does not change frequently)
- Documentation sites (mainly static information)
- Product pages (product information updated every few days to weeks)
SSG implementation example
You can implement SSG using getStaticProps.
export async function getStaticProps() {
const res = await fetch('https://api.example.com/posts');
const posts = await res.json();
return {
props: { posts }, // Fetch data at build time and pass it to the page
};
}
Data is fetched from an API and saved as static HTML at build time.
What is ISR (Incremental Static Regeneration)?
ISR is an extended version of SSG that allows you to generate pages statically while reflecting the latest data at regular intervals. This balances site freshness and performance.
-
Characteristics of ISR
- Specify the revalidate option in getStaticProps to regenerate the page after a certain time.
- Keeps static pages up to date even after build.
- Maintains the benefits of CDN caching while ensuring some real-time capability.
- Handles dynamic data without the full load of SSR (Server-Side Rendering).
-
Use cases for ISR
- News sites (articles updated periodically)
- E-commerce product pages (stock and prices change)
- Pages showing rankings or trends
-
ISR implementation example
In Next.js, you can enable ISR simply by setting revalidate in getStaticProps.
export async function getStaticProps() {
const res = await fetch('https://api.example.com/products');
const products = await res.json();
return {
props: { products },
revalidate: 60, // Regenerate the page every 60 seconds
};
}
The page is updated with the latest data every 60 seconds, making it suitable for content that requires some real-time freshness.
How ISR Works
-
First request
On the first request, Next.js runs getStaticProps and generates the page.
That page is cached on the CDN and served to all users. -
Regeneration timing
After the revalidate time has passed, when a new request comes in, regeneration is performed in the background.
The new request receives the old page, and once the new page is ready, it is served for subsequent requests.
Performance Improvements with SSG / ISR
By properly using SSG and ISR in Next.js, you can gain the following benefits:
- Faster page load times
Static pages are delivered from the CDN, resulting in fast rendering. - Reduced server load
Compared to SSR, which calls APIs on every request, you can greatly reduce processing load. - Better SEO (Search Engine Optimization)
Since HTML is generated in advance, crawlers can properly evaluate the content. - Achieve fast delivery while maintaining real-time capability
Using ISR, you can appropriately deliver frequently updated data.
Optimizing Client-Side Cache (Applying Service Workers)
Service Workers are scripts that sit between the browser and the network and have the following characteristics:
- Run in the background and function independently of page reloads
- Can intercept network requests and manage cache and API requests
- A core technology of PWAs (Progressive Web Apps), enabling offline support and performance improvements
Here we introduce how to apply Service Workers in React using Workbox.
Install Workbox
npm install workbox-window
Create serviceWorker.js in your project’s public/ directory and configure Service Worker registration and cache strategies.
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, CacheFirst, NetworkFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
// Resources to cache at build time
precacheAndRoute(self.__WB_MANIFEST || []);
// Cache static resources (CSS, JS, images)
registerRoute(
({ request }) => request.destination === 'style' || request.destination === 'script' || request.destination === 'image',
new CacheFirst({
cacheName: 'static-resources',
plugins: [
new ExpirationPlugin({
maxEntries: 50, // Maximum number of cached entries
maxAgeSeconds: 30 * 24 * 60 * 60, // Keep for 30 days
}),
],
})
);
// Cache API responses (stale-while-revalidate)
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new StaleWhileRevalidate({
cacheName: 'api-cache',
})
);
// Delete old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.filter((cacheName) => cacheName !== 'static-resources' && cacheName !== 'api-cache')
.map((cacheName) => caches.delete(cacheName))
);
})
);
});
Register the Service Worker in the React entry point src/main.tsx.
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { registerSW } from './serviceWorkerRegistration';
const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// Register Service Worker
registerSW();
Create serviceWorkerRegistration.ts to manage the Service Worker in React.
import { Workbox } from 'workbox-window';
export function registerSW() {
if ('serviceWorker' in navigator) {
const wb = new Workbox('/serviceWorker.js');
wb.addEventListener('installed', (event) => {
if (event.isUpdate) {
console.log('A new version of the Service Worker has been installed.');
}
});
wb.addEventListener('waiting', () => {
console.log('Service Worker is waiting.');
});
wb.addEventListener('activated', () => {
console.log('Service Worker is now active.');
});
wb.register();
}
}
Create a component to manually clear cache.
import React from 'react';
const ClearCacheButton: React.FC = () => {
const clearCache = async () => {
if ('caches' in window) {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map((name) => caches.delete(name)));
alert('Cache has been cleared!');
}
};
return <button onClick={clearCache}>Clear Cache</button>;
};
export default ClearCacheButton;
Add this button to App.tsx to enable cache clearing.
import React from 'react';
import ClearCacheButton from './components/ClearCacheButton';
const App: React.FC = () => {
return (
<div>
<h1>React + Service Worker + Workbox</h1>
<ClearCacheButton />
</div>
);
};
export default App;
- Prevent issues where cache updates are not reflected
- Make debugging easier for developers
- Allow clearing only specific data
- Clear only image cache while keeping API responses
- Clear only specific API responses
- Delete only CSS/JS cache while keeping important data (such as auth tokens)
Implementing a cache clear button like this can be very useful.
Setting Appropriate Cache Policies
stale-while-revalidate(cache-first + background update)
- Mechanism: Immediately respond from cache while fetching the latest data in the background
- Use cases: API responses, static files fetched from CDNs
import { StaleWhileRevalidate } from 'workbox-strategies';
registerRoute(
({ request }) => request.destination === 'script' || request.destination === 'style',
new StaleWhileRevalidate({
cacheName: 'static-resources',
})
);
Benefit: Fast page display while data is automatically updated.
network-first(network-first + cache fallback)
- Mechanism: Fetch data from the network, and fall back to cache on failure
- Use cases: API data where users need the latest information
import { NetworkFirst } from 'workbox-strategies';
registerRoute(
({ url }) => url.pathname.startsWith('/api/'),
new NetworkFirst({
cacheName: 'api-cache',
networkTimeoutSeconds: 5, // Use cache if no response within 5 seconds
})
);
Benefit: Fetches the latest data whenever possible while still showing data offline.
cache-first(cache-first + network fallback)
- Mechanism: Prefer cache, and fetch from network if cache is missing
- Use cases: Resources that rarely change (images, fonts, CSS)
import { CacheFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
registerRoute(
({ request }) => request.destination === 'image',
new CacheFirst({
cacheName: 'image-cache',
plugins: [
new ExpirationPlugin({
maxEntries: 50, // Maximum number of cached entries
maxAgeSeconds: 30 * 24 * 60 * 60, // Valid for 30 days
}),
],
})
);
Benefit: Prioritizes site display speed.
Designing Automatic Cache Clearing Rules
Cache management is important for balancing performance optimization and serving fresh data. By setting appropriate rules, you can prevent unnecessary cache buildup and achieve efficient data delivery.
Managing Cache Expiration
By properly setting cache retention periods, you can prevent wasteful cache while still delivering fresh data to users.
-
Setting Cache-Control headers
max-age=<seconds>: Keep cache valid for the specified number of secondsmust-revalidate: Expired cache must be revalidatedno-cache: Cache is stored but must be validated with the server before useno-store: Completely disable caching
-
Using ETag
- The server generates a hash of the content and reuses cache when there are no changes.
- The client sends If-None-Match, and if unchanged, receives 304 Not Modified.
Using Versioning
To properly clear cache, it is effective to embed version information in asset URLs.
-
Add versions to file names
-
Provide different URLs when changes occur so old cache is not referenced
- Example:
- /static/js/app.v1.2.3.js
- /static/css/style.v2.1.0.css
- Example:
-
Use CDN cache clear APIs to immediately reflect new versions
- Example: use Cloudflare’s cache purge feature
Introducing Query Caching (Using React Query / Apollo Client)
In React applications, using React Query or Apollo Client is effective for efficient data fetching and cache management. This enables optimization of network requests and performance improvements.
Using React Query
React Query is a library that makes it easy to manage caching for any data fetching, including REST APIs and GraphQL.
- Data fetching with
useQuery
Using React Query’suseQueryhook, you can automate data fetching and cache management.
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
const fetchData = async () => {
const { data } = await axios.get('https://jsonplaceholder.typicode.com/posts');
return data;
};
const Posts = () => {
const { data, error, isLoading } = useQuery({
queryKey: ['posts'], // Query identifier key (used for cache management)
queryFn: fetchData, // Data fetching function
staleTime: 1000 * 60, // Consider data "fresh" for 1 minute
cacheTime: 1000 * 300 // Keep cache for 5 minutes
});
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<ul>
{data.map((post: { id: number; title: string }) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
};
export default Posts;
- Adjusting
staleTimeandcacheTime
staleTime: Time until cache is considered stale. Within this time, cache is used without refetching.cacheTime: Time cache remains in memory. After this time, cache is deleted.
- Manually updating cache with invalidateQueries
When data changes, you can invalidate specific queries and fetch the latest data.
import { useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
const fetchPosts = async () => {
const { data } = await axios.get('https://jsonplaceholder.typicode.com/posts');
return data;
};
const Posts = () => {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
const refreshData = () => {
queryClient.invalidateQueries({ queryKey: ['posts'] }); // Invalidate cache and refetch data
};
if (isLoading) return <p>Loading...</p>;
return (
<div>
<button onClick={refreshData}>Refresh</button>
<ul>
{data.map((post: { id: number; title: string }) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
};
export default Posts;
Using Apollo Client
Apollo Client is a data management library specialized for GraphQL APIs, with powerful query caching using InMemoryCache.
Setting up Apollo Client
import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client';
const client = new ApolloClient({
uri: 'https://your-graphql-endpoint.com/graphql',
cache: new InMemoryCache(),
});
const App = () => (
<ApolloProvider client={client}>
<YourComponent />
</ApolloProvider>
);
export default App;
Data fetching with useQuery
import { gql, useQuery } from '@apollo/client';
const GET_POSTS = gql`
query GetPosts {
posts {
id
title
}
}
`;
const Posts = () => {
const { data, loading, error } = useQuery(GET_POSTS, {
fetchPolicy: 'cache-first', // Prefer using cache
});
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<ul>
{data.posts.map((post: { id: number; title: string }) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
};
export default Posts;
In Apollo Client, you can control data fetching behavior by specifying fetchPolicy.
| fetchPolicy | Description |
|---|---|
| cache-first | Use cache if available, otherwise fetch from network |
| network-only | Always fetch from network |
| cache-and-network | Use cache immediately and also fetch from network |
| no-cache | Do not use cache; always fetch from network |
By using refetchQueries after a GraphQL mutation, you can keep specific data up to date.
import { gql, useMutation } from '@apollo/client';
const CREATE_POST = gql`
mutation CreatePost($title: String!) {
createPost(title: $title) {
id
title
}
}
`;
const GET_POSTS = gql`
query GetPosts {
posts {
id
title
}
}
`;
const CreatePost = () => {
const [createPost] = useMutation(CREATE_POST, {
refetchQueries: [{ query: GET_POSTS }], // Refetch query
});
const handleCreate = async () => {
await createPost({ variables: { title: 'New Post' } });
};
return <button onClick={handleCreate}>Create Post</button>;
};
export default CreatePost;
React Query vs Apollo Client
| Item | React Query | Apollo Client |
|---|---|---|
| Main use | REST API / GraphQL | GraphQL API |
| Cache management | useQuery, cacheTime, staleTime |
InMemoryCache |
| Data updates | invalidateQueries |
refetchQueries |
| Data fetching control | ` | fetchPolicy (cache-first, etc.) |
Analyzing and Optimizing Cache Hit Rate
After applying cache strategies, it is important to quantitatively evaluate their effectiveness and optimize them. Here we explain in detail how to measure cache hit rates.
Client-Side Measurement
Cache hit ratio indicates the proportion of requests that were served from cache. To confirm cache effectiveness and tune settings, measure it using the following methods.
- Measurement using the browser and Service Worker
- Use the browser’s Performance API
- Use
performance.getEntriesByType("resource")to check whether resources were fetched from cache. - Resources with
transferSize === 0can be considered cache hits.
- Use
performance.getEntriesByType("resource").forEach((entry) => {
console.log(entry.name, entry.transferSize === 0 ? "Cache Hit" : "Cache Miss");
});
- Analyze Service Worker logs
- In the Service Worker, hook the fetch event and log the result of
cache.match(event.request). - Compare cache-hit and cache-miss requests.
- In the Service Worker, hook the fetch event and log the result of
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
console.log("Cache Hit:", event.request.url);
return cachedResponse;
} else {
console.log("Cache Miss:", event.request.url);
return fetch(event.request);
}
})
);
});
- Measuring GraphQL cache hit rate (Apollo Client)
Use cache.readQuery() to check whether a query hits the cache.
const { cache } = useApolloClient();
const data = cache.readQuery({
query: GET_USER_PROFILE,
variables: { id: "123" },
});
console.log(data ? "Cache Hit" : "Cache Miss");
- Use
Apollo DevToolsand check thecachetab to see which data is stored in cache. - Set
useQuery’sfetchPolicyto "cache-first" and measure response speed on cache hits.
Aggregating Cache Hit Rate on the Server Side
In addition to the client side, you can log and aggregate cache hit rates on the server and CDN to understand system-wide trends.
- Analyze Nginx or Apache logs
- Log Nginx’s $upstream_cache_status and aggregate cache hit rates.
- Analyze the ratio of HIT/MISS/BYPASS and optimize cache settings.
log_format cache_status '$remote_addr - $upstream_cache_status - $request';
access_log /var/log/nginx/cache.log cache_status;
- Log cache status at the GraphQL or API layer
- In Apollo Server or REST APIs, log the number of cache hits (
cache.get()). - Monitor hit rates of Redis or memory caches (e.g., LruCache) with tools like Datadog.
const redis = require("redis");
const client = redis.createClient();
const cacheKey = `user:${userId}`;
client.get(cacheKey, (err, data) => {
if (data) {
console.log("Cache Hit");
} else {
console.log("Cache Miss");
}
});
- Analyze CDN logs (Cloudflare, AWS CloudFront)
- For
Cloudflare, check cache hit rate via thecf-cache-statusheader. - For
AWS CloudFront, log thex-cacheheader (HIT/MISS) and aggregate it.
Conclusion
By designing and operating cache properly, you can dramatically improve application performance. Optimizing static content delivery with CDNs, reducing database load with Redis / Memcached, leveraging API response caching, prefetching with Next.js ISR / SSG, and introducing query caching each have their own benefits and use cases.
However, appropriate TTL settings and well-designed automatic cache clearing rules are indispensable. Excessive caching can compromise data consistency, while insufficient caching can degrade performance. Build an optimal cache strategy while analyzing and monitoring cache hit rates.
I hope this article helps improve the performance and scalability of your application.
Questions about this article 📝
If you have any questions or feedback about the content, please feel free to contact us.Go to inquiry form
Related Articles
Robust Authorization Design for GraphQL and REST APIs: Best Practices for RBAC, ABAC, and OAuth 2.0
2024/05/13Introduction to Automating Development Work: A Complete Guide to ETL (Python), Bots (Slack/Discord), CI/CD (GitHub Actions), and Monitoring (Sentry/Datadog)
2024/02/12Chat App (with Image/PDF Sending and Video Call Features)
2024/07/15CI/CD Strategies to Accelerate and Automate Your Development Flow: Leveraging Caching, Parallel Execution, and AI Reviews
2024/03/12Getting Started with Building Web Servers Using Go × Echo: Learn Simple, High‑Performance API Development in the Shortest Time
2023/12/03Go × Gin Advanced: Practical Techniques and Scalable API Design
2023/11/29Go × Gin Basics: A Thorough Guide to Building a High-speed API Server
2023/11/23Article & Like API with Go + Gin + GORM (Part 1): First, an "implementation-first controller" with all-in-one CRUD
2025/07/13