Lettuce: A Java Developer's Guide to the Redis Client
Most Java developers running Lettuce did not choose it. spring-boot-starter-data-redis chose it for them, and it worked, and that was the end of the decision. Which is fine until the day a connection stops recovering, a pod runs out of heap while Redis is down, or a cluster failover leaves commands going to a node that no longer owns the slot.
The getting-started material for Lettuce is covered well elsewhere. What is not covered is the handful of defaults that decide how it behaves under those conditions, and two of them are documented incorrectly in Lettuce's own reference guide. This guide is about those. Everything here applies equally to Valkey.
What Lettuce Is, and Why You Already Have It
Lettuce is a Redis client built on Netty. Its defining property, and the one every other decision follows from, is that a single connection is thread-safe and multiplexed: many threads can issue commands over one socket, and responses are matched back to callers as they arrive — pipelining, applied automatically. Synchronous, asynchronous and reactive APIs are three views onto that same connection.
This is the opposite of the model most Java developers bring from JDBC, where a connection is a scarce, single-user resource and a pool is mandatory. Getting that difference wrong is the single most common Lettuce configuration mistake, and it is the subject of the section after next.
The current release is 7.7.0.RELEASE, published 18 August 2026. It targets Java 8 as a baseline and is tested up to Java 24, so an upgrade is rarely blocked by your JDK. The project also moved GitHub organisations and now lives at redis/lettuce; the old GitHub wiki is still online but carries a notice that documentation has moved to the reference guide. Cite the latter — the wiki is stale.
Connecting: RedisURI and the Three APIs
The dependency, and a connection:
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
<version>7.7.0.RELEASE</version>
</dependency>
RedisClient client = RedisClient.create("redis://localhost:6379/0");
try (StatefulRedisConnection<String, String> connection = client.connect()) {
RedisCommands<String, String> sync = connection.sync();
sync.set("book:978-0134685991", "Effective Java");
String title = sync.get("book:978-0134685991");
}
client.shutdown();
The same connection also gives you connection.async(), returning RedisFuture, and connection.reactive(), returning Project Reactor types. You do not open a second connection to switch models.
RedisURI can be built fluently instead of parsed, which is what you want once credentials and timeouts are involved:
RedisURI uri = RedisURI.builder()
.withHost("redis.internal")
.withPort(6379)
.withAuthentication("app", "secret".toCharArray())
.withTimeout(Duration.ofSeconds(5))
.withDatabase(0)
.build();
You Probably Do Not Need a Connection Pool
This is worth stating before anything else, because most teams arrive at Lettuce carrying a pool they do not need and a mental model that will mislead them for years.
Lettuce's own documentation puts it plainly: "Lettuce is thread-safe by design which is sufficient for most cases. All Redis user operations are executed single-threaded. Using multiple connections does not impact the performance of an application in a positive way." And, in the same passage: "Connection pooling always comes with a cost of complexity and maintenance."
Redis executes commands one at a time. Ten connections do not make it execute ten at a time; they change neither latency nor throughput in your favour. What a pool buys you against a multiplexed client is not throughput — it is isolation, and you only need isolation for the commands that break multiplexing.
The RedisClient javadoc names them exactly: "Multiple threads may share one connection if they avoid blocking and transactional operations such as BLPOP and MULTI/EXEC."
- Blocking commands.
BLPOP,BRPOP,XREAD BLOCKand friends occupy the connection until they return. Every other thread sharing it waits. Lettuce's pipelining documentation says so directly: "all invocations of the shared connection will be blocked until the blocking command returns." - Transactions.
MULTIopens a state that belongs to a connection, not a caller. Two threads queuing commands into the sameMULTIproduce one transaction containing both. SELECT. The selected database is connection-scoped, so a thread switching databases switches them for everyone. This one is reasoning rather than a documented rule, but it follows from the same property.
If you do need a pool, note first that commons-pool2 is an optional dependency of lettuce-core: it is not pulled in transitively, and pooling will not compile until you add it yourself:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.12.0</version>
</dependency>
GenericObjectPool<StatefulRedisConnection<String, String>> pool =
ConnectionPoolSupport.createGenericObjectPool(
() -> client.connect(), new GenericObjectPoolConfig<>());
try (StatefulRedisConnection<String, String> connection = pool.borrowObject()) {
connection.sync().multi();
// ... queued commands ...
connection.sync().exec();
}
Connections are wrapped by default, which is why close() above returns the connection to the pool rather than closing the socket. There is also a non-blocking variant, AsyncConnectionPoolSupport, returning a BoundedAsyncPool, for reactive and asynchronous code that must not block a thread waiting to borrow.
Timeouts: The Documentation Is Wrong Here
Lettuce's client options reference lists the default for timeoutOptions as "Do not timeout commands." That has not been true for some time.
In the source, ClientOptions declares:
public static final TimeoutOptions DEFAULT_TIMEOUT_OPTIONS = TimeoutOptions.enabled();
And TimeoutOptions.enabled() is builder().timeoutCommands().connectionTimeout().build(): command timeouts on, applying the connection timeout. Checking published sources artifacts version by version places the change at 6.5.0:
| Lettuce version | DEFAULT_TIMEOUT_OPTIONS | Commands time out? |
|---|---|---|
| 6.2.7, 6.3.2, 6.4.2 | TimeoutOptions.create() | No |
| 6.5.0 | TimeoutOptions.enabled() | Yes |
| 6.8.2, 7.7.0 | TimeoutOptions.enabled() | Yes |
The practical default is therefore 60 seconds, inherited from RedisURI.DEFAULT_TIMEOUT. A command that exceeds it fails with RedisCommandTimeoutException and the message Command timed out after 1 minute(s).
There is a second trap pointing the other way, and it catches people who read the first one and decide to be explicit. TimeoutOptions.DEFAULT_TIMEOUT_COMMANDS is false, and that is the builder's default, not the client's. So this quietly disables the timeouts you thought you were configuring:
// Wrong: timeoutCommands() was never called, so commands no longer time out at all.
ClientOptions options = ClientOptions.builder()
.timeoutOptions(TimeoutOptions.builder().build())
.build();
// Right:
ClientOptions options = ClientOptions.builder()
.timeoutOptions(TimeoutOptions.enabled(Duration.ofSeconds(5)))
.build();
The BLPOP Timeout Everybody Hits
A related consequence deserves its own heading, because the failure looks like a Lettuce bug and is not. BLPOP key 0 means "block indefinitely" to Redis. Lettuce's command timeout does not know that, so at the 60-second default the call fails with RedisCommandTimeoutException while the server is doing exactly what it was asked.
Lettuce's FAQ states the cause as "the configured command timeout applies without considering command-specific timeouts." Three fixes, in increasing order of precision: raise the global timeout, use a blocking timeout comfortably below it, or attach a TimeoutSource that reads the timeout from the command itself:
TimeoutOptions timeoutOptions = TimeoutOptions.builder()
.timeoutSource(new TimeoutSource() {
@Override
public long getTimeout(RedisCommand<?, ?, ?> command) {
if (command.getType() == CommandType.BLPOP) {
return TimeUnit.MILLISECONDS.toNanos(
CommandArgsAccessor.getFirstInteger(command.getArgs()));
}
return -1; // fall back to the default timeout
}
}).build();
One caveat from the same page, worth knowing before you tune anything: "commands that timed out may block the connection until either the timeout exceeds or Redis sends a response." A timeout does not free the connection immediately.
Reconnection, and the Unbounded Queue
Lettuce reconnects automatically — autoReconnect defaults to true — and what it does with commands issued while disconnected is governed by DisconnectedBehavior. From the enum's own javadoc:
| Value | Behaviour |
|---|---|
DEFAULT | Accept commands when auto-reconnect is enabled, reject commands when auto-reconnect is disabled. |
ACCEPT_COMMANDS | Accept commands in disconnected state. |
REJECT_COMMANDS | Reject commands in disconnected state. |
Accepting commands while disconnected means queuing them. And this is where the default that matters lives: requestQueueSize defaults to Integer.MAX_VALUE. Lettuce's FAQ does not soften it: "Lettuce auto-reconnects by default to Redis to minimize service disruption. Commands issued while there's no Redis connection are buffered and replayed once the server connection is reestablished. By default, the queue is unbounded which can lead to memory exhaustion."
So a Redis outage does not degrade a busy service gracefully. It fills the heap. Bound the queue:
ClientOptions options = ClientOptions.builder()
.requestQueueSize(10_000)
.disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS)
.build();
client.setOptions(options);
Once bounded, overflow surfaces as a RedisException carrying one of two messages, both worth knowing, because searching for the second turns up much less than the first:
Request queue size exceeded: 10000. Commands are not accepted until the queue size drops.
Command buffer size exceeded: 10000. Commands are not accepted until the queue size drops.
On a cluster the limit is per connection, and a cluster client holds many. Lettuce documents the effective ceiling as requestQueueSize * ((number of cluster nodes * 2) + 1), so a six-node cluster with a 10,000 queue can hold 130,000 commands, not 10,000. Size accordingly.
Command timeouts have several causes beyond a slow server, and Lettuce's FAQ lists one that is entirely self-inflicted: blocking the event loop from inside a RedisFuture callback, a reactive pipeline, a pub/sub listener or a RedisConnectionStateListener. Its advice is a single sentence — "Never block the EventLoop from your code."
TLS
Switch schemes and TLS is on:
RedisURI uri = RedisURI.builder()
.withHost("redis.internal")
.withPort(6380)
.withSsl(true)
.withVerifyPeer(SslVerifyMode.FULL) // the default
.build();
Or rediss://redis.internal:6380 in URI form; redis+ssl, redis+tls and rediss-sentinel are also recognised. Verification defaults to FULL, and the three modes are: NONE — no verification at all; CA — verify the certificate chain but not the hostname; FULL — full verification. Note that CA still reports isVerifyPeer() as true, so that method is not a reliable check for "hostname is verified".
Custom trust material goes through SslOptions, which also carries protocols, cipher suites, the JDK-versus-OpenSSL provider choice, and a handshake timeout:
SslOptions sslOptions = SslOptions.builder()
.jdkSslProvider()
.truststore(new File("/etc/certs/redis-truststore.jks"), "changeit")
.build();
client.setOptions(ClientOptions.builder().sslOptions(sslOptions).build());
StartTLS is off by default and enabled with withStartTls(true). Left alone, Lettuce uses the JVM's own trust store, usually cacerts. Our guide to connecting to Valkey or Redis over TLS covers the server side and the certificate handling this depends on.
The behaviour to plan around is what happens when a handshake fails after the connection has been working. Lettuce's SSL documentation: "If an SSL handshake fails on reconnect (because of peer/certification verification or peer does not talk SSL) reconnection will be disabled for the connection." A certificate rotation that your truststore does not recognise therefore does not cause a retry loop — it takes the instance out permanently, with one error line in the log. Reconnect behaviour on TLS connections is strictly less forgiving than on plaintext ones, and expiry monitoring matters more as a result.
Codecs
A codec turns your keys and values into bytes. The default is UTF-8 strings: connect()'s javadoc says it "treats keys and values as UTF-8 strings", which is why the examples above are <String, String>.
Two alternatives ship in the box. ByteArrayCodec hands you raw bytes, which is what you want when another system owns the format. CompressionCodec wraps another codec with GZIP or DEFLATE:
RedisCodec<String, byte[]> codec = CompressionCodec.valueCompressor(
new ByteArrayCodec(), CompressionCodec.CompressionType.GZIP);
StatefulRedisConnection<String, byte[]> connection = client.connect(codec);
For objects, you implement RedisCodec<K, V>: four methods, two decode and two encode. One warning that connects back to the timeouts section: a codec that throws while encoding desynchronises the protocol state and shows up later as unexplained command timeouts. Make encoding total.
If you find yourself writing serialization for every value type, that is the point at which a data-structure client starts to earn its place. Our guide to serialization codecs for Valkey and Redis covers the alternatives, and the serialization entry covers the concepts.
ClientResources: Expensive, Shared, and Yours to Close
Behind every RedisClient sits a ClientResources holding Netty event loop groups, a computation executor, a timer, an event bus and a DNS resolver. The RedisClient javadoc is blunt about the consequence: "RedisClient is an expensive resource. It holds a set of netty's EventLoopGroup's that use multiple threads. Reuse this instance as much as possible or share a ClientResources instance amongst multiple client instances."
Thread pool sizing is the same for I/O and computation: max(Runtime.getRuntime().availableProcessors(), 2), overridable with the io.netty.eventLoopThreads system property. If you have read that Lettuce uses three times the processor count, that came from a heading in its own documentation that the code no longer matches — there is no such multiplier in current source.
ClientResources resources = DefaultClientResources.create();
RedisClient primary = RedisClient.create(resources, primaryUri);
RedisClient analytics = RedisClient.create(resources, analyticsUri);
Sharing like this introduces the one lifecycle rule people get wrong. A client that created its own resources disposes of them on shutdown(). A client that was handed resources does not:
"Client instances with using shared ClientResources … won't shut down the ClientResources on their own. The ClientResources instance needs to be shut down once it's not used anymore."
primary.shutdown();
analytics.shutdown();
resources.shutdown(); // yours to call - nobody else will
Miss that last line and the event loop threads outlive the clients. In a long-running application that redeploys contexts, it is a slow leak that looks like a framework problem.
Metrics
Lettuce collects command latency metrics, and DefaultCommandLatencyCollectorOptions.DEFAULT_ENABLED is true. Read that alone and you would assume a fresh application is recording latencies. It almost certainly is not.
The collector only functions when both HdrHistogram and LatencyUtils are on the classpath, and both are optional dependencies of lettuce-core. When they are absent, DefaultClientResources substitutes CommandLatencyRecorder.disabled() and carries on. No warning, no exception — the metrics simply are not there.
<dependency>
<groupId>org.hdrhistogram</groupId>
<artifactId>HdrHistogram</artifactId>
<version>2.2.2</version>
</dependency>
<dependency>
<groupId>org.latencyutils</groupId>
<artifactId>LatencyUtils</artifactId>
<version>2.0.3</version>
</dependency>
With them present, latencies arrive as CommandLatencyEvent objects on Lettuce's event bus rather than in a metrics registry, and the publication interval defaults to 10 minutes, a figure regularly misread as 10 seconds, which then makes the data look broken:
resources.eventBus().get()
.filter(e -> e instanceof CommandLatencyEvent)
.cast(CommandLatencyEvent.class)
.subscribe(e -> log.info("{}", e.getLatencies()));
Latencies are tracked per remote endpoint and per command type, with percentiles at 50, 90, 95, 99 and 99.9 in microseconds, and are reset after each event by default.
The Micrometer path is usually the better one, because it lands in the registry your application already exports:
ClientResources resources = ClientResources.builder()
.commandLatencyRecorder(
new MicrometerCommandLatencyRecorder(meterRegistry, MicrometerOptions.create()))
.build();
That produces two timers, lettuce.command.completion and lettuce.command.firstresponse, tagged with command, local and remote. Local socket distinction is off by default, so local reads ANY until you enable it. Histogram publication is also off by default, and the percentile and min/max latency settings only take effect once it is on. See Redis client metrics in Java for how this fits a wider observability setup.
Tracing
Tracing is an SPI rather than a built-in, and it defaults to Tracing.disabled(). Two integrations ship: Brave, and Micrometer Tracing, which is also the route to OpenTelemetry, since there is no direct OTel integration in Lettuce itself.
ClientResources resources = ClientResources.builder()
.tracing(new MicrometerTracing(observationRegistry, "Redis"))
.build();
One security note before you enable the Brave integration in an environment with real data: all command arguments are included in span tags by default. Keys, and the values of write commands, will reach your tracing backend. excludeCommandArgsFromSpanTags() turns that off, and on most systems it should be on from the first deployment rather than after the first audit. Our guide to Redis client tracing in Java covers the wider picture.
Cluster: Topology Refresh Is Off Until You Turn It On
A Redis Cluster client keeps a view of which node owns which slot. That view is read once, when the client connects. Periodic refresh is disabled by default: DEFAULT_PERIODIC_REFRESH_ENABLED is false.
The failure this produces is a familiar one. A primary fails over at 02:00, a replica is promoted, and your client keeps routing to a node that no longer owns the slot, until something restarts and the topology is read again. Turn it on:
ClusterTopologyRefreshOptions topologyRefresh = ClusterTopologyRefreshOptions.builder()
.enablePeriodicRefresh(Duration.ofSeconds(30))
.enableAllAdaptiveRefreshTriggers()
.build();
clusterClient.setOptions(ClusterClientOptions.builder()
.topologyRefreshOptions(topologyRefresh)
.build());
Periodic refresh polls on a schedule, 60 seconds if you enable it without specifying. Adaptive refresh reacts to events such as MOVED and ASK redirections and reconnect failures, so it responds in seconds rather than at the next poll. Use both. For the connection setup itself, see how to connect to a Redis cluster in Java.
Where reads go is a separate setting, and one worth having an opinion about:
connection.setReadFrom(ReadFrom.REPLICA_PREFERRED);
The options are UPSTREAM, UPSTREAM_PREFERRED, REPLICA, REPLICA_PREFERRED, ANY_REPLICA, LOWEST_LATENCY and ANY, plus subnet and regex forms. Two naming notes for anyone reading older code: MASTER and SLAVE are still present as the former names of UPSTREAM and REPLICA, and NEAREST is simply an alias for LOWEST_LATENCY. Reading from replicas trades consistency for read capacity — Redis replication is asynchronous, so a read-after-write may not see the write.
Lettuce in Spring Boot
Spring Boot's documentation states that spring-boot-starter-data-redis "by default … uses Lettuce", and LettuceConnectionConfiguration is annotated matchIfMissing = true, so Lettuce wins whenever both clients are present. Switching is a property — see Jedis vs Lettuce for when that is worth doing.
spring:
data:
redis:
host: redis.internal
port: 6379
timeout: 5s
lettuce:
shutdown-timeout: 100ms
cluster:
refresh:
period: 30s
One property deserves a closer look, given the first section of this guide. spring.data.redis.lettuce.pool.enabled is documented as "Enabled automatically if 'commons-pool2' is available" — so any other starter that drags commons-pool2 onto your classpath silently switches you to a pooled Lettuce factory, with max-active and max-idle of 8 and a max-wait of -1ms, meaning block forever. That is a pool you did not ask for, in front of a client that does not need one. Set it explicitly rather than inheriting it:
spring:
data:
redis:
lettuce:
pool:
enabled: false
Anything the properties do not reach — ClientResources, custom codecs, SslOptions, a TimeoutSource — goes through a LettuceClientConfigurationBuilderCustomizer bean. Note that none of this touches Spring's cache abstraction, which sits a layer above the client. And if what you are configuring is a cache rather than a client, our Spring Boot Redis cache guide covers RedisCacheManager and the parts of caching that Lettuce configuration does not touch.
When You Need More Than a Client
Everything above configures Lettuce well, and for a great many services that is the whole job. Lettuce is a command client: it gives you Redis commands over a fast, thread-safe, well-behaved connection, and it does that better than most.
The limits show up when what you want is not a command but a data structure. A distributed lock on Lettuce is SET NX PX, plus a watchdog to renew it, plus a Lua script to release it safely, plus tests for the cases in between — code you own forever. Per-entry TTL inside a hash is not a Redis primitive before 7.4. A near cache means writing a pub/sub invalidation listener over a local map — the pattern Redis calls client-side caching.
This is where a higher-level client like Redisson fits. It is not a faster Lettuce; it is a different layer:
RLock lock = redisson.getLock("order:4711");
lock.lock(); // lease renewal handled for you
try {
// ...
} finally {
lock.unlock();
}
RMapCache<String, Book> books = redisson.getMapCache("books");
books.put("978-0134685991", book, 30, TimeUnit.MINUTES, 10, TimeUnit.MINUTES);
A near cache with invalidation is configuration rather than code, and its local eviction can be backed by Caffeine:
LocalCachedMapOptions<String, Book> options = LocalCachedMapOptions.<String, Book>name("books")
.cacheProvider(CacheProvider.CAFFEINE)
.syncStrategy(SyncStrategy.INVALIDATE) // the default
.cacheSize(10_000)
.timeToLive(Duration.ofMinutes(30));
RLocalCachedMap<String, Book> books = redisson.getLocalCachedMap(options);
RLock, RMapCache, RLocalCachedMap and RedissonSpringCacheManager are all open-source under Apache 2.0. Putting a near cache behind @Cacheable — RedissonSpringLocalCachedCacheManager — requires Redisson PRO, as do the clustered and V2 cache managers.
Redisson is also a heavier dependency that holds its own connections and wants its own tuning. If your application needs GET, SET and a sorted set, staying on Lettuce is the right call and adding a second client is not. If you are weighing the two properly, Redisson vs Lettuce puts them side by side, and the migration guide covers what changes if you move.
Frequently Asked Questions
What Is Lettuce in Redis?
Lettuce is a Java client for Redis and Valkey, built on Netty. Its distinguishing feature is that a single connection is thread-safe and multiplexes commands from many threads, which is why most Lettuce applications need no connection pool. It exposes synchronous, asynchronous and reactive APIs over that same connection, and it is the client Spring Boot uses by default through spring-boot-starter-data-redis.
What Is a Redis Client?
A Redis client is the library your application uses to speak the Redis protocol: opening connections, encoding commands, decoding replies, and handling reconnection, cluster topology and failover. In Java the common choices are Lettuce and Jedis, which expose Redis commands directly, and Redisson, which exposes Java data structures such as Map, Lock and Queue backed by Redis.
Which Redis Client Is Best for Java?
It depends on what you are building. Lettuce suits applications that need Redis commands with high concurrency and few connections, and it is already there in Spring Boot. Jedis is simpler and blocking, and needs a pool because its connections are not thread-safe. Redisson suits applications that want distributed locks, collections and caching as Java objects rather than as commands they assemble themselves. Many teams run Lettuce for the data path and add Redisson where coordination is needed.
Does Lettuce Need a Connection Pool?
Usually not. Lettuce's own documentation says pooling "is not necessary in most cases" and that "using multiple connections does not impact the performance of an application in a positive way", because Redis executes commands one at a time regardless. A pool is warranted for blocking commands such as BLPOP, for MULTI/EXEC transactions, and where threads need their own database via SELECT — cases that break connection sharing. Pooling also requires adding commons-pool2, which is not a transitive dependency.
Why Do I Get RedisCommandTimeoutException?
Command timeouts are enabled by default with a 60-second limit, despite what Lettuce's client-options table says. Common causes are an overloaded or unreachable server, a blocking command such as BLPOP with a timeout longer than the client's, a codec that throws while encoding, and blocking the Netty event loop from inside a callback, reactive pipeline or pub/sub listener. Check the event loop first — it is the most common self-inflicted cause and the least obvious.
Next Steps
A Lettuce client with an explicit command timeout, a bounded request queue, topology refresh enabled, shared ClientResources that something actually closes, and metrics that are genuinely recording will behave predictably in production. That is a short list, and the defaults do not give you any of it.
From here, connecting to Redis in Java covers the ground across clients, and Redis Cluster and Redis Sentinel cover the topologies this configuration is protecting you against.
If distributed locks, collections or a near cache are on your list, try Redisson PRO free or compare the editions in the feature comparison.