PlanetScale’s read replicas are not a silver bullet for scaling read traffic, they’re more like a highly sophisticated scaling lever that requires understanding the underlying mechanics of how your application interacts with your database.

Let’s see this in action. Imagine a common scenario: a busy e-commerce site.

// Fetch product details
func GetProduct(ctx context.Context, db *sql.DB, productID string) (*Product, error) {
	var p Product
	err := db.QueryRowContext(ctx, "SELECT id, name, price, description FROM products WHERE id = ?", productID).Scan(&p.ID, &p.Name, &p.Price, &p.Description)
	if err != nil {
		return nil, fmt.Errorf("failed to get product %s: %w", productID, err)
	}
	return &p, nil
}

// Fetch products in a category (potentially many rows)
func GetProductsByCategory(ctx context.Context, db *sql.DB, categoryID string) ([]Product, error) {
	rows, err := db.QueryContext(ctx, "SELECT id, name, price FROM products WHERE category_id = ?", categoryID)
	if err != nil {
		return nil, fmt.Errorf("failed to get products for category %s: %w", categoryID, err)
	}
	defer rows.Close()

	var products []Product
	for rows.Next() {
		var p Product
		if err := rows.Scan(&p.ID, &p.Name, &p.Price); err != nil {
			return nil, fmt.Errorf("failed to scan product row: %w", err)
		}
		products = append(products, p)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("error iterating product rows: %w", err)
	}
	return products, nil
}

When you add a read replica in PlanetScale, it’s not just a copy of your data. It’s a separate instance of your database that is eventually consistent with the primary. This means that writes to your primary database will eventually propagate to the read replica, but there’s a small delay. This delay is critical.

The primary instance handles all writes. When you scale up your application and direct read traffic to a read replica, you’re essentially offloading SELECT queries from the primary. This is incredibly useful for read-heavy workloads where the primary is becoming a bottleneck due to too many SELECT statements, not INSERT, UPDATE, or DELETE statements.

The key to managing read replicas effectively lies in understanding your query patterns and the consistency requirements of your application.

How it works internally:

PlanetScale uses a system called "vitess" under the hood. Vitess shards your database horizontally. When you create a read replica, you’re essentially creating a read-only copy of one or more of these shards. Vitess manages the replication process, ensuring that data flows from the primary shard(s) to the read replica shard(s). Your application connects to these read replicas using separate connection strings.

The levers you control:

  1. Connection Pooling: You’ll need to configure your application to use separate connection pools for your primary and read replica databases. This is crucial for directing traffic appropriately. For example, in Go, you’d create two *sql.DB instances, each configured with its own connection string and pool settings.

    // Primary DB connection
    primaryDB, err := sql.Open("mysql", "user:password@tcp(primary-host:3306)/database?parseTime=true")
    if err != nil {
        log.Fatal(err)
    }
    primaryDB.SetMaxOpenConns(50) // Example: Adjust based on load
    
    // Read Replica DB connection
    replicaDB, err := sql.Open("mysql", "user:password@tcp(replica-host:3306)/database?parseTime=true")
    if err != nil {
        log.Fatal(err)
    }
    replicaDB.SetMaxOpenConns(100) // Example: Higher for read-heavy
    
  2. Query Routing: This is where your application logic decides which database to use. For read-only operations, you’d use replicaDB. For any write operations (INSERT, UPDATE, DELETE), you must use primaryDB. Many ORMs and database drivers allow you to specify which connection to use per query.

    // Example: In a web framework, you might have middleware
    func ReadOnlyHandler(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // Assuming you have a way to get the replica DB connection from the request context
            ctx := context.WithValue(r.Context(), "db", replicaDB)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
    
    func WriteHandler(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // Assuming you have a way to get the primary DB connection from the request context
            ctx := context.WithValue(r.Context(), "db", primaryDB)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
    
  3. Replication Lag Monitoring: You need to actively monitor the replication lag between your primary and read replicas. If the lag becomes too high, your read replicas might serve stale data, which can cause subtle and hard-to-debug bugs. PlanetScale provides tools to monitor this.

    -- Example SQL query to check replication status (specific to MySQL/Vitess)
    SHOW REPLICA STATUS FOR CHANNEL 'group_replication_recovery';
    -- Look for 'Seconds_Behind_Master'
    

The most surprising thing about read replicas is that they don’t magically solve all scaling problems; they introduce a new dimension of complexity: eventual consistency. Your application must be designed to tolerate or actively manage this. For instance, if a user performs an action that modifies data (e.g., updates their profile), and then immediately tries to view that updated data, you must ensure that the read request goes to the primary or that you have a mechanism to wait for replication to catch up. This is often handled by reading from the primary for a short period after a write, or by refreshing the read replica connection.

The next challenge you’ll face is understanding how to intelligently route queries based on their transactional needs and consistency requirements.

Want structured learning?

Take the full Planetscale course →