Database Read/Write Splitting: Handling Replica Lag and Sticky Primary Reads

How to Prevent Stale Data: Forcing Primary Reads After Database Writes

How to Prevent Stale Data: Forcing Primary Reads After Database Writes


As web apps grow, database read traffic heavily dominates writes. There is an architectural pattern to deal with heavy read loads called database replication. The idea is simple: have one master database which processes all the INSERT/UPDATE/DELETE queries and one or more replicas which process all the SELECT queries.

Laravel has a built-in solution for read/write splitting, but having replicas introduces a new issue called replication lag. The user who just updated their profile will see the old data if his request is routed to a replica which hasn't applied the changes yet. We'll look into configuring Laravel's read/write splitting, discuss why it can cause issues and learn how to avoid reading from replicas in certain cases.

In this Laravel tutorial, we'll learn how to configure read/write splitting in Laravel, why it can cause issues and how to avoid reading from replicas in certain cases.

How Laravel Handles Read/Write Connections

Laravel lets you define separate read and write hosts for a database connection. Under the hood, Laravel examines the SQL query:

  • Queries that change data (INSERT / UPDATE / DELETE) will go to the write connection
  • Queries that retrieve data (SELECT) will go to the read connection

No need to adjust your Eloquent queries

1. Configuring Read/Write Splitting

Open config/database.php and locate your database connection (for example, mysql). You can split the hosts by specifying read and write arrays:

'mysql' => [
    'driver' => 'mysql',
    'read' => [
        'host' => [
            env('DB_READ_HOST_1', '192.168.1.11'),
            env('DB_READ_HOST_2', '192.168.1.12'),
        ],
    ],
    'write' => [
        'host' => [
            env('DB_WRITE_HOST', '192.168.1.10'),
        ],
    ],
    'sticky' => true,
    'port' => env('DB_PORT', '3306'),
    'database' => env('DB_DATABASE', 'forge'),
    'username' => env('DB_USERNAME', 'forge'),
    'password' => env('DB_PASSWORD', ''),
    'charset' => 'utf8mb4',
    'collation' => 'utf8mb4_unicode_ci',
    'prefix' => '',
    'strict' => true,
    'engine' => null,
],


Notice that the read.host entry is an array. When multiple read hosts are defined, Laravel randomly selects one for each request, acting as a basic round-robin load balancer.

The Replication Lag Problem: Read-Your-Own-Writes

Most database replication mechanisms (MySQL asynchronous or semi-synchronous replication, among others) are not instantaneous. The time between the moment when a write operation is committed on the primary server and the moment it is visible on the read replica is called replication lag.

Consider this standard controller flow:

public function update(Request $request, Post $post)
{
    // 1. Sent to the WRITE database
    $post->update($request->validated());

    // 2. Sent to the READ replica database!
    // If replication lags by even 50ms, this fetches outdated values!
    $freshPost = Post::find($post->id);

    return response()->json($freshPost);
}


Because the read happens immediately after the write, the replica may still hold the previous state of the row. This violates the Read-Your-Own-Writes consistency guarantee.

Solution 1: Enabling the sticky Configuration Option

The cleanest way to solve replication lag within the same HTTP request is enabling the 'sticky' => true option in config/database.php.

'mysql' => [
    'read' => [ ... ],
    'write' => [ ... ],
    'sticky' => true, // Enables sticky read-after-write
    // ...
],


When sticky is true,

all SELECT queries will go to the read replica by default at the beginning of the request. But as soon as any INSERT/UPDATE/DELETE query is executed, Laravel will flip the internal flag for the rest of the current request, and all following SELECT queries will go to the write connection.

This way, if your code writes something to the database during the same request, any subsequent reads will also be executed on the master to see the latest data.

Solution 2: Forcing Reads from the Primary (Write) Connection Manually

Sometimes you need to read from the primary database even if no writes have occurred on the current request. An example of this is reading from the database for payment callback checks or to verify inventory levels during a checkout process.

Laravel provides several methods of forcing reads to occur on the write node.

1. On an Eloquent Query with useWritePdo()

You can call useWritePdo() directly on the Eloquent model or Query Builder instance:

namespace App\Http\Controllers;

use App\Models\Account;
use Illuminate\Http\Request;

class CheckoutController extends Controller
{
    public function process(Request $request)
    {
        // Forces this SELECT query to execute against the primary/writer database
        $account = Account::onWriteConnection()->findOrFail($request->user()->account_id);

        // Alternatively, using useWritePdo() on the query builder:
        $balance = Account::query()
            ->useWritePdo()
            ->where('id', $request->user()->account_id)
            ->value('balance');

        // ...
    }
}


Both onWriteConnection() and useWritePdo() force the query to bypass replicas and hit the writer node directly.

2. Inside Database Transactions

Whenever you wrap queries inside DB::transaction(), Laravel automatically executes all queries (both reads and writes) on the write connection by default:

use Illuminate\Support\Facades\DB;
use App\Models\Order;

DB::transaction(function () use ($orderId) {
    // Automatically queries the WRITE database because it is inside a transaction
    $order = Order::lockForUpdate()->find($orderId);

    $order->update(['status' => 'processing']);
});


Solution 3: Handling Redirects Across Requests (Session-Based Stickiness)

The "sticky" config option only protects queries within a single HTTP request. The common web application pattern is POST-Redirect-GET:

  • User submits a form via POST /profile (Request 1, writes to primary, returns redirect).
  • Browser issues GET /profile (Request 2, a new HTTP request).

Since Request 2 is a new request cycle, sticky resets and the GET query goes to the read replica. If your replica lag is 200ms, the user will see old data when they reload the page.

The solution is to write a tiny middleware that tracks when a user performed a write and for a few seconds after, force-redirect all requests to the primary.

Step 1: Create the StickyReads Middleware

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;

class EnsureFreshReadsAfterWrite
{
    /**
     * Handle an incoming request.
     */
    public function handle(Request $request, Closure $next): Response
    {
        // Check if user recently performed a write within the last 5 seconds
        $lastWrite = session('last_db_write_timestamp');

        if ($lastWrite && (now()->timestamp - $lastWrite) < 5) {
            // Force default database connection to use the write PDO for all reads
            DB::connection()->useWritePdo();
        }

        $response = $next($request);

        // If the current request was a modifying HTTP method, record timestamp in session
        if (in_array($request->method(), ['POST', 'PUT', 'PATCH', 'DELETE'])) {
            session(['last_db_write_timestamp' => now()->timestamp]);
        }

        return $response;
    }
}


Step 2: Register the Middleware

Add this middleware to your web middleware group. Now every time a user submits a form, any following redirect and eventual page load within the next 5 seconds will be reading from the primary database instead of having inconsistent delays due to replication.

Feature Comparison: Replica Read Strategies

Strategy Scope Primary Read Trigger Best Use Case
Default (Non-sticky) Per-query None (all SELECTs go to replica). Read-heavy apps with near-zero replication lag.
'sticky' => true Single HTTP Request Automatic after any write query in that request. API responses returning updated models immediately.
onWriteConnection() / useWritePdo() Specific Query Explicit method call in code. Financial balances, order checkout checks.
Session-based Middleware Multi-request (Time window) State-modifying HTTP methods (POST/PUT/DELETE). Standard web apps with POST-Redirect-GET flows.


Important Things to Remember

  • 📌 Keep Writes Low on the Primary: Forcing all reads to the primary defeats the purpose of having read replicas. Only allow primary reads when the data freshness is critical to the user experience or business logic.
  • ⚡ Monitoring Replica Lag: Always monitor your replica lag metrics in production (e.g. Seconds_Behind_Master in MySQL or replication lag bytes in PostgreSQL) and optimize slow queries or upgrade replica hardware if replication lag regularly spikes to several seconds.
  • ⚠️ Schema Migrations: When running migrations via php artisan migrate, Laravel automatically uses the write connection to ensure schema changes are applied directly to the primary node.

Conclusion

Read/write splitting is an efficient way to scale Laravel database performance if the application has a heavy load. The main advantage of using it is that the user will be able to see their updated data due to the sticky option and replication lag knowledge plus specific calls to useWritePdo().

Thank you for reading this article 😊

For any query, do not hesitate to comment 💬


Previous Post Next Post

Contact Form