Configuring Spring Boot Read Replica DataSource Routing

This guide is part of Spring Boot DataSource Configuration. Adding a read replica is the standard response to a primary that is running out of read capacity, and in Spring Boot it is mostly a connection pool problem: you end up with two pools, two sets of limits, and one routing decision made per transaction.

The routing itself is straightforward. The parts that go wrong in production are the sizing — two pools multiply the fleet’s connection count — and read-after-write consistency, which replication lag breaks in ways that are intermittent and hard to reproduce.

The routing mechanism

Spring’s AbstractRoutingDataSource picks a target data source per connection acquisition, based on a key you supply. Combined with TransactionSynchronizationManager.isCurrentTransactionReadOnly(), the key can be derived from the @Transactional(readOnly = true) flag that most codebases already have:

public class ReplicaRoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
            ? DataSourceKey.REPLICA
            : DataSourceKey.PRIMARY;
    }
}

Two details make or break this. The routing key must be resolved inside an active transaction, because isCurrentTransactionReadOnly() returns false outside one — so a repository call with no @Transactional annotation silently goes to the primary. And the routing data source must be wrapped in a LazyConnectionDataSourceProxy, or the connection is acquired before the transaction’s read-only flag is set, and every query goes to the primary regardless of annotation:

@Bean
@Primary
public DataSource dataSource(
        @Qualifier("primaryDataSource") DataSource primary,
        @Qualifier("replicaDataSource") DataSource replica) {

    ReplicaRoutingDataSource routing = new ReplicaRoutingDataSource();
    routing.setTargetDataSources(Map.of(
        DataSourceKey.PRIMARY, primary,
        DataSourceKey.REPLICA, replica));
    routing.setDefaultTargetDataSource(primary);
    routing.afterPropertiesSet();

    // Defers connection acquisition until the first statement, by which
    // time the transaction's read-only flag has been set.
    return new LazyConnectionDataSourceProxy(routing);
}

Omitting LazyConnectionDataSourceProxy is the single most common reason a routing setup “does not work”: everything is wired correctly, no error appears, and 100% of traffic goes to the primary.

Why the lazy proxy is required Without the proxy, the transaction manager acquires a connection before setting the read-only flag, so the routing key is not yet available and the primary is chosen. With the proxy, acquisition is deferred until the first statement, after the flag has been set. Routing depends entirely on when the connection is acquired without the lazy proxy transaction begins connection acquired now read-only flag not set yet routing key resolves to null default target chosen primary, every time replica sits idle with the lazy proxy transaction begins no connection taken read-only flag set from the annotation first statement runs now the key is read replica selected The lazy proxy also stops read-only transactions that never query from borrowing a connection at all.
The read-only flag is set after the transaction begins. Deferring acquisition is what lets the router see it.

Two pools, configured independently

Both data sources need their own HikariCP configuration, and giving them the same numbers is almost always wrong — they carry different workloads:

app:
  datasource:
    primary:
      jdbc-url: jdbc:postgresql://orders-primary.internal:5432/orders
      username: orders_app
      maximum-pool-size: 10
      minimum-idle: 10
      pool-name: primary-pool
      connection-timeout: 3000
      max-lifetime: 280000
      keepalive-time: 120000
      leak-detection-threshold: 12000
    replica:
      jdbc-url: jdbc:postgresql://orders-replica.internal:5432/orders
      username: orders_ro
      maximum-pool-size: 20
      minimum-idle: 20
      pool-name: replica-pool
      connection-timeout: 3000
      max-lifetime: 280000
      keepalive-time: 120000
      leak-detection-threshold: 12000
      read-only: true
@Bean
@ConfigurationProperties("app.datasource.primary")
public HikariDataSource primaryDataSource() {
    return DataSourceBuilder.create().type(HikariDataSource.class).build();
}

@Bean
@ConfigurationProperties("app.datasource.replica")
public HikariDataSource replicaDataSource() {
    return DataSourceBuilder.create().type(HikariDataSource.class).build();
}

Three points about that configuration.

The replica pool is larger because reads are the majority of the traffic and are what you moved. Sizing both at the old single-pool value doubles your connection count for no reason — the correct move is to split the original budget in proportion to the traffic split, then adjust.

read-only: true on the replica pool sets the JDBC connection to read-only, which makes an accidental write fail immediately and locally rather than reaching the replica and producing a confusing cannot execute INSERT in a read-only transaction from PostgreSQL. It is a cheap guard against a routing bug.

Distinct pool-name values are essential. Every metric, log line and leak warning is tagged with the pool name; without distinct names you cannot tell which pool saturated. The mechanics of that warning are in configuring the HikariCP leak detection threshold.

The fleet arithmetic doubles, and this is the part that surprises teams:

before:  20 pods x 20 connections            = 400 to the primary
after:   20 pods x (10 primary + 20 replica) = 200 primary + 400 replica

Read replicas have their own max_connections, usually the same as the primary’s. Adding a replica adds capacity for reads, not for connections, and a fleet that was already close to the primary’s ceiling will now be close to two ceilings.

Replication lag and read-after-write

This is the failure that reaches users. A user submits a form, the write commits on the primary, the response redirects to a page that reads through the replica, and the replica has not yet applied the change. The user sees their own edit missing.

There is no configuration that fixes this in general; the lag is physics. What works is deciding, per read, whether it can tolerate lag:

Route the read to the primary when it must see a recent write. The simplest correct approach: within a session or request that has just written, use the primary for subsequent reads. Mark those transactions without readOnly = true, or set an explicit routing hint.

Use a bounded staleness check. On PostgreSQL you can compare the replica’s replay position against the LSN of your write, and fall back to the primary when the replica has not caught up:

// after committing on the primary
String lsn = jdbc.queryForObject("SELECT pg_current_wal_lsn()::text", String.class);
requestScope.setWriteLsn(lsn);

// before a replica read in the same request
Boolean caughtUp = replicaJdbc.queryForObject(
    "SELECT pg_last_wal_replay_lsn() >= ?::pg_lsn", Boolean.class, lsn);

That is more machinery than most applications need, but it is the only approach that is actually correct rather than probabilistic.

Accept staleness explicitly for reads that can tolerate it. Dashboards, search results, and listings are usually fine on a replica. Account balances and confirmation pages usually are not. Making that a deliberate per-endpoint decision — rather than a blanket readOnly = true on every read method — is the design that survives contact with production.

Monitor the lag itself so you know when the assumption breaks:

SELECT client_addr, state,
       pg_wal_lsn_diff(sent_lsn, replay_lsn) AS replay_lag_bytes,
       replay_lag
FROM pg_stat_replication;
Splitting one budget rather than duplicating it Adding a replica pool without reducing the primary pool doubles the fleet's connection count. The correct move is to divide the existing budget in proportion to the traffic split. Adding a replica adds read capacity, not connection capacity before 20 pods x 20 to the primary naive split 20 primary + 20 replica each the problem two ceilings, both near the limit correct split 10 primary + 20 replica each The replica has its own max_connections, usually the same as the primary's, and your fleet now draws on both. Start from the existing total and divide it by the measured read and write mix.
Divide the existing connection budget between the two pools rather than giving each the old total.

Common failure patterns and remediation

Symptom Underlying cause Remediation
All traffic still goes to the primary No LazyConnectionDataSourceProxy Wrap the routing data source in the lazy proxy
Some reads go to the primary unexpectedly Repository method has no @Transactional(readOnly = true) Annotate, or make read-only the default at the service layer
cannot execute INSERT in a read-only transaction A write path annotated read-only Fix the annotation; keep read-only: true on the pool as the guard
Users see stale data after their own writes Replication lag on a read-after-write path Route those reads to the primary, or check replay position
Database ceiling exhausted after adding the replica Two pools per pod, sized as if each were the only one Split the original budget rather than duplicating it
Cannot tell which pool saturated Both pools share a name Set distinct pool-name values
Replica pool idle, primary saturated Read-only transactions not reaching the router Verify the lookup key with a debug log inside determineCurrentLookupKey
Failover leaves the replica pool pointing at a promoted primary Endpoints hard-coded rather than using the reader endpoint Use the provider’s reader endpoint and cap connection age

Verifying it works

Do not trust the wiring; measure the split. Micrometer publishes per-pool metrics tagged with the pool name, so the ratio is directly observable:

sum by (pool) (rate(hikaricp_connections_acquire_seconds_count[5m]))

You should see both pools carrying traffic in roughly the proportion you expect. A replica pool at zero means routing is not happening; a primary pool at zero means writes are not happening, which is its own problem.

A cheap functional check during development is to log the resolved key:

@Override
protected Object determineCurrentLookupKey() {
    Object key = TransactionSynchronizationManager.isCurrentTransactionReadOnly()
        ? DataSourceKey.REPLICA : DataSourceKey.PRIMARY;
    if (log.isDebugEnabled()) log.debug("routing to {}", key);
    return key;
}

Leave that at DEBUG. It fires on every connection acquisition and will flood production logs if enabled there.

Choosing a target per read A decision path for each read endpoint. If the read must reflect a write made in the same request, it goes to the primary. If bounded staleness is acceptable, it goes to the replica. Otherwise a replay position check decides. Staleness tolerance is an endpoint-level decision, not a global one a read is about to run must it reflect a write from this request? yes no route to the primary omit readOnly on this transaction route to the replica annotate readOnly = true when unsure, compare the replica replay position first A blanket readOnly = true on every read method is what produces intermittent stale-read bug reports.
Route by staleness tolerance, endpoint by endpoint. The blanket annotation is what causes the stale-read reports.

Operational boundary

Routing splits load; it does not add connection capacity. Both endpoints have their own ceiling, and the fleet now consumes from both. If the reason for adding a replica was too many clients rather than read CPU saturation, routing does not solve it — a proxy does, and the arithmetic is in Connection Pool Sizing Formulas.

Failover is the other boundary. When the primary fails over, the writer endpoint moves, and long-lived pooled connections stay bound to the old host until they are retired. Keep max-lifetime short enough that endpoints are re-resolved regularly — the mechanism is described in tuning HikariCP maxLifetime and idleTimeout, and the managed-database specifics in AWS Aurora Connection Scaling.

Frequently Asked Questions

Why does everything go to the primary despite correct annotations?
Almost certainly a missing LazyConnectionDataSourceProxy. Without it the connection is acquired before the transaction’s read-only flag is set, so the router has nothing to route on.
Do I need two entity managers?
No. One entity manager over the routing data source is enough, because the routing happens at connection acquisition, below JPA. Two entity managers are only needed if the schemas differ.
How should I split the connection budget between the pools?
Roughly in proportion to the traffic split, starting from your existing total rather than duplicating it. Adjust from measured saturation on each pool separately.
What stops an accidental write reaching the replica?
Set read-only: true on the replica pool. The JDBC driver then rejects the write locally with a clear error instead of sending it.
How do I handle read-after-write consistency?
Route those specific reads to the primary, or compare the replica’s replay position against the LSN of your write before reading. A blanket read-only annotation on all reads guarantees intermittent stale reads.
Can I route by something other than the read-only flag?
Yes. The lookup key can come from anything — a ThreadLocal set by an interceptor, a custom annotation, a tenant identifier. The read-only flag is convenient because most codebases already have it.
Does this work with multiple replicas?
Add more target data sources and a key per replica, or point the replica data source at the provider’s reader endpoint and let it distribute. The reader endpoint is simpler and handles replica addition and removal without a deploy.