Week 11: Caching & Performance

Weeks 9 and 10 grew your system into a set of cooperating services, and the database underneath has been queried heavily since Week 4 — which means it's a good time to make sure things stay fast as real load shows up. This week is about keeping the hot paths quick: the Spring Cache abstraction backed by Redis, tuning the HikariCP connection pool that sits between your app and the database, developing a basic feel for query performance and indexing, and offloading slow, non-critical work with @Async instead of making callers wait on it.

Module 8 of 12 Week 11 of 15 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Cache expensive, frequently-read data with Spring's cache abstraction backed by Redis
  • Understand and tune connection pooling with HikariCP
  • Identify when to offload work asynchronously with @Async, and when it doesn't help

1. The Spring Cache Abstraction

Spring's cache abstraction lets you add caching to a method with an annotation, without hand-writing "check the cache, and if it's empty, compute and store the result" logic yourself. It's an abstraction over a cache provider — this week you'll wire it to Redis, but the annotations on your service methods stay identical no matter which provider sits behind them.

Turn it on with @EnableCaching on a configuration class, then annotate the methods worth caching:

CacheConfig.java
package com.codeverse.week11.config;

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableCaching
public class CacheConfig {
}

Think back to Week 4/5's ProjectService.getProjectWithTasks(Long id) — a read that joins a project with its tasks, gets hit on nearly every page load of a project's detail view, and changes only when someone edits the project or its tasks. That combination — read often, change rarely — is exactly the profile worth caching:

ProjectService.java
package com.codeverse.week11.project;

import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
public class ProjectService {

    private final ProjectRepository projectRepository;

    public ProjectService(ProjectRepository projectRepository) {
        this.projectRepository = projectRepository;
    }

    @Cacheable(value = "projects", key = "#id")
    public ProjectDetailDto getProjectWithTasks(Long id) {
        // Only runs on a cache miss -- the join query from Week 4/5
        return projectRepository.findProjectWithTasks(id)
            .orElseThrow(() -> new ProjectNotFoundException(id));
    }

    @CachePut(value = "projects", key = "#result.id()")
    public ProjectDetailDto updateProject(Long id, UpdateProjectRequest request) {
        // Runs the update, then refreshes the cache entry with the new value
        var project = projectRepository.findById(id)
            .orElseThrow(() -> new ProjectNotFoundException(id));
        project.rename(request.name());
        return ProjectDetailDto.from(projectRepository.save(project));
    }

    @CacheEvict(value = "projects", key = "#id")
    public void deleteProject(Long id) {
        projectRepository.deleteById(id);
    }
}

@Cacheable checks the named cache ("projects") for an entry under key before running the method body at all; on a hit, the method doesn't execute — the cached value comes straight back. @CachePut always runs the method, then stores its return value under the given key, which is the right choice for writes that should keep the cache in sync rather than just wipe it. @CacheEvict removes an entry, which is the right choice for deletes. The key expressions use Spring Expression Language (SpEL) to pull the cache key from a method argument or the return value.

Caching hides a slow query — it doesn't fix one

If findProjectWithTasks is slow because of Week 5's N+1 problem (one query for the project, then one more per task), caching only masks that cost on repeat reads. The first request for every project, and every request after a cache miss or eviction, still pays the full N+1 penalty. Fix the query shape first; cache what's left.

2. Redis as a Cache Store

Without any provider configured, Spring's default cache implementation is an in-memory ConcurrentHashMap per cache name, living inside your application's JVM heap. That's fine for a single instance running locally, but it breaks down the moment you have more than one — which, after Week 9's move toward multiple services (and any service you'd scale to more than one replica), is exactly where you end up. Each instance would keep its own separate cache: instance A evicts an entry after an update, but instance B is still happily serving the stale cached value, because the two caches never talk to each other.

A distributed cache — one process, or cluster, shared by every instance — fixes that. Redis is the standard choice: an in-memory key-value store that's fast, supports expiration natively, and every instance of your app talks to the same Redis, so a cache write or eviction from one instance is immediately visible to all the others. Add the starter:

pom.xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
application.properties
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.cache.type=redis

With those two starters and properties on the classpath, Spring Boot auto-configures a RedisCacheManager for you. For real control over expiration you'll usually define it explicitly, so every cache doesn't inherit the same one-size-fits-all TTL:

CacheConfig.java
package com.codeverse.week11.config;

import java.time.Duration;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .disableCachingNullValues()
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer()));

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(defaultConfig)
            // A tighter TTL for a cache that goes stale faster than most
            .withCacheConfiguration("projects", defaultConfig.entryTtl(Duration.ofMinutes(5)))
            .build();
    }
}

A TTL (time-to-live) is a safety net, not your primary invalidation strategy — it guarantees a stale entry can't live forever even if you forget to evict it somewhere. The primary strategy is explicit: every write path that changes cached data should evict or update the relevant entry, the way updateProject and deleteProject did above. This ties directly back to Week 5's @Transactional writes — the cache eviction should happen as part of the same unit of work as the database write it's invalidating, so a rolled-back transaction doesn't leave you having evicted a cache entry for a change that never actually committed.

3. Connection Pooling with HikariCP

Opening a TCP connection to a database, authenticating, and negotiating a session is expensive relative to running a query — doing it fresh for every single request would dominate your response times before the query itself ever ran. A connection pool solves this by opening a fixed set of connections once, up front, and handing them out to threads that need one, returning each connection to the pool when the thread is done rather than closing it.

Spring Boot uses HikariCP as its default pool whenever spring-boot-starter-data-jpa is on the classpath — you likely haven't had to configure it explicitly since Week 4, because Boot auto-configures a HikariDataSource with reasonable defaults. Under real load, two settings matter most:

application.properties
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.connection-timeout=30000
spring.datasource.hikari.minimum-idle=10
spring.datasource.hikari.idle-timeout=600000
spring.datasource.hikari.max-lifetime=1800000

maximum-pool-size caps how many connections HikariCP will ever hold open at once. Bigger isn't automatically better: your database has its own limit on total concurrent connections (shared across every service instance talking to it), and each open connection costs the database memory whether or not it's doing anything. A common starting point is sized around the number of CPU cores available to the database, not the number of app threads — HikariCP's own guidance suggests a pool in the range of ((core_count * 2) + effective_spindle_count) is usually enough, and pools far larger than that tend to add contention without adding throughput. connection-timeout is how long a thread will wait for a connection to free up before giving up and throwing an exception — 30 seconds by default, which is already generous; a request stuck waiting that long is a symptom, not something to paper over by raising the timeout further.

When every connection in the pool is checked out and a new request needs one, that request's thread blocks until a connection is returned or connection-timeout elapses, at which point it fails with a SQLTransientConnectionException. A pool that's too small under real concurrency shows up as exactly this — timeouts and rising latency that don't correlate with the database itself being slow, only with how many requests are in flight at once.

You won't really know until Week 12

Every number in this section is a reasonable starting point, not a verified answer for your app. Week 12 covers measuring pool utilization, cache hit rate, and latency with real metrics — that's what turns "10 sounds about right" into a pool size you can actually defend.

4. Query Performance Basics

You don't need to become a database internals expert to reason about query performance — you need enough of a mental model to notice when a query is doing far more work than it should. Most relational databases can show you a query plan, the step-by-step strategy the database chose to execute a query, using EXPLAIN:

psql
EXPLAIN SELECT * FROM tasks WHERE project_id = 42;

-- Without an index:
-- Seq Scan on tasks  (cost=0.00..1850.00 rows=40 width=64)
--   Filter: (project_id = 42)
-- -> scans every row in the table to find matches

-- With an index on project_id:
-- Index Scan using idx_tasks_project_id on tasks  (cost=0.29..8.31 rows=40 width=64)
--   Index Cond: (project_id = 42)
-- -> jumps straight to the matching rows

A Seq Scan ("sequential scan") means the database read every row in the table to find the ones that match — fine on a table with a few hundred rows, increasingly costly as a table grows into the millions. An Index Scan uses a separate, pre-sorted structure to jump straight to matching rows without touching the rest of the table. The columns most worth indexing are foreign keys (like tasks.project_id, which every "get a project's tasks" query filters on) and any column your app frequently filters or sorts by in a WHERE or ORDER BY clause:

V9__add_task_project_id_index.sql
CREATE INDEX idx_tasks_project_id ON tasks (project_id);

Add it as a Flyway migration, the same way you've versioned every schema change since Week 5, so the index ships with the code that depends on it and applies identically in every environment.

This connects directly back to Week 5's N+1 problem — a query executed once per row of an outer result set is another major performance killer, and often a bigger one than a missing index, because it doesn't just make one query slow, it multiplies a cheap query by however many rows came back. The two problems compound: an N+1 query pattern running against an un-indexed foreign key is the worst of both worlds, and fixing only one of them still leaves you with a slow endpoint.

5. Async Processing with @Async

Not every part of handling a request needs to finish before the response goes back to the caller. Sending a Week 10-style notification when a task is completed is a good example: the caller cares that their update was saved, not that an email or push notification was dispatched — making them wait on that extra network call adds latency for no benefit to them.

Enable async method execution with @EnableAsync, then mark the method to run in the background with @Async:

AsyncConfig.java
package com.codeverse.week11.config;

import java.util.concurrent.Executor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "notificationExecutor")
    public Executor notificationExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(8);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("notif-async-");
        executor.initialize();
        return executor;
    }
}
NotificationService.java
package com.codeverse.week11.notification;

import java.util.concurrent.CompletableFuture;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class NotificationService {

    private final EmailClient emailClient;

    public NotificationService(EmailClient emailClient) {
        this.emailClient = emailClient;
    }

    @Async("notificationExecutor")
    public CompletableFuture<Void> sendTaskCompletedNotification(Long taskId, String recipientEmail) {
        emailClient.send(recipientEmail, "Task Completed", "Your task #" + taskId + " is done.");
        return CompletableFuture.completedFuture(null);
    }
}

Calling sendTaskCompletedNotification(...) from a controller or another service returns almost immediately — Spring hands the actual method body to the notificationExecutor thread pool and runs it on a separate thread, while the calling thread carries on and returns the HTTP response without waiting. Returning CompletableFuture<Void> (rather than void) gives the caller the option to observe completion or failure later if it ever needs to, without forcing it to.

A dedicated executor, like notificationExecutor above, matters more than it might look: without one, @Async falls back to Spring's default SimpleAsyncTaskExecutor, which creates a brand-new thread for every single call rather than reusing a bounded pool — fine for a demo, dangerous under real load, where a burst of async calls can spin up an unbounded number of threads and exhaust the JVM. Naming a pool per workload (notifications, exports, reports, and so on) also means one runaway workload can't starve the others.

@Async is not Week 10's messaging

@Async runs a method on another thread inside the same JVM — if the app crashes mid-call, the work is simply lost, and it only exists as long as this one process is up. Week 10's message broker persists a message durably outside the process and can retry delivery across restarts and multiple consumer instances. Use @Async for cheap, best-effort, in-process work; use messaging when the work needs to survive a crash or fan out to other services.

Self-invocation silently ignores @Async

Spring implements @Async (like @Cacheable and @Transactional) through a proxy that wraps your bean. Calling an @Async method from another method within the same classthis.sendTaskCompletedNotification(...) — bypasses that proxy entirely, so the call runs synchronously on the calling thread with no error or warning. The call has to come from a different bean for the proxy, and the async behavior, to actually apply.

6. Hands-on Exercise

Hands-on

Cache, pool, index, and offload your way to a faster service

Take one of your existing endpoints from Weeks 4–10 and apply this week's four techniques to it end to end.

Requirements:

  1. Add Redis caching to a frequently-read endpoint such as GET /projects/{id}: wire up spring-boot-starter-data-redis, configure a RedisCacheManager with a sensible TTL (a few minutes, not hours), and annotate the read method with @Cacheable.
  2. Evict or update the cache entry from every write path that changes that data — the corresponding update endpoint should use @CachePut (or an explicit eviction) and the delete endpoint should use @CacheEvict, so no request can ever see stale data past your TTL.
  3. Tune your app's HikariCP pool settings (maximum-pool-size, connection-timeout at minimum) and write down, in a comment or README, the reasoning behind the numbers you chose — what you're assuming about concurrent load and database capacity.
  4. Add a Flyway migration that creates an index on a foreign key or a frequently-filtered column in your schema, and confirm with EXPLAIN that a representative query switches from a sequential scan to an index scan.
  5. Pick one slow or non-critical operation (sending a notification, generating a report, logging an audit event) and convert it to run via @Async on its own named thread pool, called from a different bean than the one defining it — confirm with a log statement (or thread name) that it actually runs on a separate thread.
Hint

Run Redis locally with docker run -p 6379:6379 redis:7 if you don't already have it installed — no separate configuration needed beyond pointing spring.data.redis.host at it.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does adding a second instance of your service require a distributed cache like Redis, instead of the default in-memory cache?

Spring's default cache implementation is a ConcurrentHashMap living inside a single JVM's heap, so each running instance of your service keeps a completely separate, unsynchronized copy of the cache. If instance A updates a project and evicts its cache entry, instance B still has the old value cached and keeps serving it, because the two in-memory maps have no way to communicate. A distributed cache like Redis lives outside any single instance, so every instance reads from and writes to the same store — an eviction from instance A is immediately visible to instance B on its next lookup.

Q2

How should you reason about sizing a HikariCP connection pool, rather than just picking a large number?

The pool size should reflect the database's capacity to serve concurrent connections efficiently, not the number of threads or requests your app can theoretically have in flight. Every open connection costs the database memory and coordination overhead whether or not it's actively running a query, and the database also has to divide its own limited resources — CPU cores, disk I/O — across every connection from every service instance pointed at it. A reasonable starting point sizes the pool around the database's available cores rather than app concurrency, and the right way to validate any number is to measure actual pool utilization and query latency under real load (which is exactly what Week 12 covers) rather than guessing bigger is always safer.

Q3

You've cached the results of getProjectWithTasks, but the underlying query is still slow because tasks.project_id has no index. Why does that still hurt?

A cache only helps on a hit — it does nothing for the request that populates the cache in the first place, for any request after the entry's TTL expires, or for any request after a write evicts the entry. Every one of those requests still runs the full, un-indexed query, which means it still does a sequential scan over the entire tasks table to find rows matching that project_id. As the table grows, that scan gets slower regardless of how well the cache is performing on the reads it does manage to short-circuit, and a short TTL or a write-heavy workload with frequent evictions means a large share of requests never get the benefit of the cache at all.

Q4

What does @Async actually change about how a method runs, and what does it not fix?

@Async makes Spring hand the method's execution to a background thread pool instead of running it on the caller's own thread, so the caller can continue — and, in a web request, return its HTTP response — without waiting for that method to finish. What it doesn't fix is self-invocation: Spring implements @Async via a proxy wrapping the bean, and a call made from one method to another within the same class goes directly to the real object rather than through that proxy, so the annotation is silently ignored and the method runs synchronously on the calling thread with no error raised. To get the async behavior, the call has to originate from a different bean than the one that declares the @Async method.