Concurrent Writes in Practice: Building High-Throughput Apps with Turso's MVCC

What it looks like to actually build with BEGIN CONCURRENT: when to reach for it, working code in TypeScript, Python, and Rust, and how to handle conflicts.

Cover image for Concurrent Writes in Practice: Building High-Throughput Apps with Turso's MVCC

You're building a checkout flow for a multi-tenant e-commerce platform. Each tenant gets their own database. SQLite was the obvious choice: no server process, no connection pooling, no infrastructure to babysit. The checkout logic is maybe 40 lines: read inventory, validate a discount code, calculate tax, decrement stock, insert the order. Clean.

Then you run a load test. Twenty simulated users hitting the same tenant database at once. The error logs light up:

Error: database is locked (SQLITE_BUSY)

So you add a retry loop. The retry loop needs exponential backoff. The backoff needs jitter to avoid thundering herd. You add a timeout so retries don't pile up forever. By the time it's production-ready, the "simple" SQLite setup has grown a tail of complexity that makes you wonder if you should just spin up Postgres.

BEGIN CONCURRENT was built for exactly this moment.

Turso shipped concurrent writes on Turso Cloud on August 3, 2026. The announcement post and the engineering deep dive cover the theory and the benchmarks. This piece is the practical follow-up: what it looks like to actually build with BEGIN CONCURRENT, when you should reach for it, and when you shouldn't.

#Where the single-writer model breaks down

SQLite in WAL mode allows many readers alongside a single writer. It's a deliberate design decision. For the workloads SQLite was built against (embedded systems, mobile apps, desktop software where one user at a time is the norm) it's still fine. The trouble starts when a write transaction holds the lock while doing real work like reading from the database, running business logic, calling an external service. Every millisecond of compute inside that transaction is a millisecond every other writer is blocked. A modest concurrent access pattern, a transaction that does a little validation before writing, and your second writer is sitting in a backoff loop.

SQLite achieves roughly 150k rows per second with single-threaded batched inserts, even with full synchronous mode and fsync() on every commit. But adding threads does nothing. The single-writer lock means only one thread makes progress at a time, regardless of how many you throw at it.

Where it gets worse is when transactions include compute. Turso's engineering team benchmarked this by adding a CPU busy loop inside each transaction to simulate real work like parsing, aggregation, and ML inference. SQLite throughput drops as compute time increases, regardless of thread count. Under a single-writer model, you can't parallelize what's serialized.

With Turso's MVCC, the picture changes. At 8 threads with 1ms of compute per transaction, Turso write transactions are 4x faster than SQLite's. Even with no compute time, Turso is 16% faster than SQLite when multiple threads are writing.

#Workloads where MVCC pays off

  • Transactions with business logic. Any transaction that reads, computes, then writes such as e-commerce checkouts, booking systems, or financial calculations where the write lock duration isn't just the write itself.

  • High-volume data ingestion across distinct rows. Thousands of rows arriving per second from sources like real-time analytics feeds, betting odds updates, or IoT telemetry, each touching different parts of the table. The writes don't conflict, so they shouldn't block each other.

  • Stream materialization. You're processing events at high speed and materializing portions into a queryable state. Without concurrent writes, you either serialize everything through one thread or maintain separate databases per partition and complicate your queries.

  • Continuous data augmentation. Batch jobs that compute aggregates, ML predictions, or classification labels and write them back. These can run for seconds or minutes. Under a single-writer model, they block the entire database for the duration.

#Workloads where single-writer is still fine

If your writes are sequential, single-threaded, and fast, such as a single-user embedded app or a write batch with no interleaved compute, plain BEGIN works and you don't need this.

#How Turso's MVCC works (enough to reason about it)

Knowing when conflicts occur and why is what lets you build correctly with concurrent writes. MVCC defers conflict detection to commit time rather than locking on transaction start, which changes where your application needs to handle failure.

Instead of locking the database when a write transaction starts, MVCC keeps multiple versions of each row in an in-memory index. When a transaction writes a row, it creates a new version. Readers continue seeing the version that was current when their transaction started. Nobody blocks.

Conflict detection happens at commit time. When a transaction tries to commit, the engine checks whether any other committed transaction modified the same rows during the interval. If not, the commit succeeds. If two transactions genuinely wrote the same row, the second one to commit gets a conflict error and must retry.

The specific approach is inspired by Hekaton (the memory-optimized engine Microsoft built into SQL Server, published at VLDB 2011 by Larson et al., "High-Performance Concurrency Control Mechanisms for Main-Memory Databases"). Hekaton laid out the latch-free, optimistic MVCC scheme that most modern in-memory engines have drawn on since. Turso adapts it to SQLite's B-tree storage, layering the MVCC index on top of the existing pager, WAL, and B-tree. If a row doesn't exist in the MVCC index, it's read through the normal path. If it does, the MVCC version is used because it's the latest. Writes go through the MVCC index, get written to a log file, and eventually checkpoint into the SQLite database file via the WAL.

The critical difference from SQLite's own experimental BEGIN CONCURRENT branch (which has never been merged into mainline) is conflict granularity. SQLite's branch detects conflicts at the page level, meaning two transactions updating completely unrelated rows that happen to share a B-tree page still collide. Turso detects conflicts at the row level, so unrelated rows sharing a page don't produce false conflicts. With page-level detection, your retry rate depends partly on how your data is physically laid out on disk (something you can't easily control). With row-level detection, retries only happen when two transactions actually touched the same data.

#Comparison table

SQLite (default)SQLite BEGIN CONCURRENT branchTurso
Writers at a timeOneSeveral, optimisticSeveral, optimistic
When locking happensTransaction startCommitCommit
Conflict granularityWhole databasePageRow
Retry requiredOn SQLITE_BUSY at startOn page conflict at commitOn row conflict at commit
AvailabilityMainlineExperimental branch onlyTurso engine, and now Turso Cloud

#Enabling and using concurrent writes on Turso Cloud

Concurrent writes are currently in early preview on the Turso Cloud. During the duration of the early preview, you need to complete three steps before any transaction can use BEGIN CONCURRENT.

1. Enable the feature in the dashboard. Go to Settings → General in the Turso Cloud dashboard and turn on concurrent writes. This is an explicit opt-in during the early preview.

2. Create a tursodb database. This is a distinct database type from the default SQLite databases on Turso Cloud. Concurrent writes require the Turso engine (the Rust rewrite), not libSQL.

Via CLI:

turso db create --tursodb my-store

Or create it through the dashboard UI, selecting the tursodb type.

3. Use BEGIN CONCURRENT instead of BEGIN. Your transactions now run with MVCC.

#TypeScript: concurrent checkout flow

import { connect } from "@tursodatabase/serverless";

const conn = connect({
  url: process.env.TURSO_DATABASE_URL!,
  authToken: process.env.TURSO_AUTH_TOKEN!,
});

// Define a transactional checkout operation
const checkout = conn.transactionAsync(async (tx, tenantId: string, itemId: string, discountCode: string | null) => {
  // Read current inventory
  const item = await tx.get(
    "SELECT id, name, price, stock FROM products WHERE id = ? AND tenant_id = ?",
    [itemId, tenantId]
  );
  if (!item) throw new Error("Item not found");
  if (item.stock <= 0) throw new Error("Out of stock");

  // Validate discount code
  let discount = 0;
  if (discountCode) {
    const code = await tx.get(
      "SELECT discount_pct, uses_remaining FROM discount_codes WHERE code = ? AND tenant_id = ?",
      [discountCode, tenantId]
    );
    if (code && code.uses_remaining > 0) {
      discount = code.discount_pct;
      await tx.run(
        "UPDATE discount_codes SET uses_remaining = uses_remaining - 1 WHERE code = ? AND tenant_id = ?",
        [discountCode, tenantId]
      );
    }
  }

  // Calculate total
  const subtotal = item.price * (1 - discount / 100);
  const tax = subtotal * 0.08;
  const total = subtotal + tax;

  // Decrement stock
  await tx.run(
    "UPDATE products SET stock = stock - 1 WHERE id = ? AND tenant_id = ?",
    [itemId, tenantId]
  );

  // Insert order
  await tx.run(
    "INSERT INTO orders (tenant_id, product_id, subtotal, tax, total, created_at) VALUES (?, ?, ?, ?, ?, datetime('now'))",
    [tenantId, itemId, subtotal, tax, total]
  );

  return { orderId: item.id, total };
});

// Run it concurrently: .concurrent() uses BEGIN CONCURRENT under the hood
const result = await checkout.concurrent("tenant_123", "item_456", "SUMMER20");

#Python: concurrent inventory update

import turso_serverless
import os

conn = turso_serverless.connect(
    os.environ["TURSO_DATABASE_URL"],
    auth_token=os.environ["TURSO_AUTH_TOKEN"],
    isolation_level="CONCURRENT",  # All implicit transactions use BEGIN CONCURRENT
)

def restock_item(product_id: str, quantity: int):
    """Add stock to a product. Safe to call concurrently for different products."""
    cursor = conn.execute(
        "SELECT id, stock FROM products WHERE id = ?", (product_id,)
    )
    row = cursor.fetchone()
    if row is None:
        raise ValueError(f"Product {product_id} not found")

    new_stock = row[1] + quantity
    conn.execute(
        "UPDATE products SET stock = ?, updated_at = datetime('now') WHERE id = ?",
        (new_stock, product_id),
    )
    conn.commit()
    return new_stock

# Or use explicit BEGIN CONCURRENT for finer control
conn2 = turso_serverless.connect(
    os.environ["TURSO_DATABASE_URL"],
    auth_token=os.environ["TURSO_AUTH_TOKEN"],
)

conn2.execute("BEGIN CONCURRENT")
conn2.execute(
    "INSERT INTO inventory_events (product_id, delta, source, created_at) VALUES (?, ?, ?, datetime('now'))",
    ("item_789", 50, "warehouse_shipment"),
)
conn2.execute(
    "UPDATE products SET stock = stock + 50 WHERE id = ?", ("item_789",)
)
conn2.commit()
conn2.close()

#Rust: concurrent data ingestion

use turso_serverless::{Builder, TransactionBehavior};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let db = Builder::new_remote(std::env::var("TURSO_DATABASE_URL")?)
        .with_auth_token(std::env::var("TURSO_AUTH_TOKEN")?)
        .build()
        .await?;

    let mut conn = db.connect()?;

    // Set connection default: every transaction uses BEGIN CONCURRENT
    conn.set_transaction_behavior(TransactionBehavior::Concurrent);

    // Ingest a batch of sensor readings
    let readings = vec![
        ("sensor_a", 23.5, "2026-08-20T14:00:00Z"),
        ("sensor_b", 18.2, "2026-08-20T14:00:00Z"),
        ("sensor_c", 31.7, "2026-08-20T14:00:00Z"),
    ];

    let tx = conn.transaction().await?;
    for (sensor_id, value, timestamp) in readings {
        tx.execute(
            "INSERT INTO readings (sensor_id, value, recorded_at) VALUES (?, ?, ?)",
            (sensor_id, value, timestamp),
        )
        .await?;

        // Update the latest-reading cache
        tx.execute(
            "INSERT OR REPLACE INTO latest_readings (sensor_id, value, recorded_at) VALUES (?, ?, ?)",
            (sensor_id, value, timestamp),
        )
        .await?;
    }
    tx.commit().await?;

    // Or use per-transaction behavior for mixed workloads
    let tx = conn
        .transaction_with_behavior(TransactionBehavior::Concurrent)
        .await?;
    tx.execute(
        "INSERT INTO readings (sensor_id, value, recorded_at) VALUES (?, ?, ?)",
        ("sensor_d", 22.1, "2026-08-20T14:01:00Z"),
    )
    .await?;
    tx.commit().await?;

    Ok(())
}

#Handling conflicts correctly

When two transactions write the same row, the second one gets a conflict error and is rolled back and then you need to retry it. The error surfaces differently depending on the SDK, and it can be raised on the conflicting write statement itself rather than at commit, but the meaning is the same: your transaction conflicted with another transaction (in the serverless SDKs it reads Write-write conflict). The retry wrapper below uses bounded retries with exponential backoff:

#TypeScript

async function withConflictRetry<T>(
  fn: () => Promise<T>,
  maxRetries = 5,
  baseDelayMs = 10
): Promise<T> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err: any) {
      const isConflict =
        err.message?.includes("SQLITE_BUSY") ||
        err.message?.includes("conflict");

      if (!isConflict || attempt === maxRetries) {
        throw err;
      }

      // Exponential backoff with jitter
      const delay = baseDelayMs * Math.pow(2, attempt) * (0.5 + Math.random() * 0.5);
      await new Promise((r) => setTimeout(r, delay));
    }
  }
  throw new Error("Unreachable");
}

// Usage
const result = await withConflictRetry(() =>
  checkout.concurrent("tenant_123", "item_456", "SUMMER20")
);

#Python

import time
import random

def with_conflict_retry(fn, max_retries=5, base_delay=0.01):
    for attempt in range(max_retries + 1):
        try:
            return fn()
        except Exception as e:
            is_conflict = "SQLITE_BUSY" in str(e) or "conflict" in str(e).lower()
            if not is_conflict or attempt == max_retries:
                raise
            delay = base_delay * (2 ** attempt) * (0.5 + random.random() * 0.5)
            time.sleep(delay)

# Usage
result = with_conflict_retry(lambda: restock_item("item_456", 10))

#Rust

use std::time::Duration;
use rand::Rng;

async fn with_conflict_retry<F, Fut, T, E>(
    mut f: F,
    max_retries: u32,
    base_delay: Duration,
) -> Result<T, E>
where
    F: FnMut() -> Fut,
    Fut: std::future::Future<Output = Result<T, E>>,
    E: std::fmt::Display,
{
    let mut rng = rand::rng();
    for attempt in 0..=max_retries {
        match f().await {
            Ok(val) => return Ok(val),
            Err(e) => {
                let msg = e.to_string();
                let is_conflict = msg.contains("SQLITE_BUSY") || msg.contains("conflict");
                if !is_conflict || attempt == max_retries {
                    return Err(e);
                }
                let jitter: f64 = 0.5 + rng.random::<f64>() * 0.5;
                let delay = base_delay.mul_f64((2.0_f64).powi(attempt as i32) * jitter);
                tokio::time::sleep(delay).await;
            }
        }
    }
    unreachable!()
}

#The hot row problem

Conflict retries are rare when your transactions touch different rows. They get expensive when many concurrent writers update the same row. If twenty concurrent transactions all decrement the same stock column (a shared sequence number, a global counter, a single "last updated" timestamp), nineteen of them will conflict. This is correct ACID behavior. The database is detecting that these transactions can't all commit without violating consistency.

When you hit this pattern, you can either redesign the hot row or accept the retry cost.

Redesign the hot row. Shard the counter across multiple rows and sum them on read. Instead of one stock row, maintain a stock_deltas table where each decrement is an insert, and compute the current stock with SELECT SUM(delta) FROM stock_deltas WHERE product_id = ?. Writes don't conflict because they're all inserts to different rows. Reads are slightly more expensive, but reads don't block under MVCC anyway.

Accept the retry cost. If the conflict rate is tolerable for your throughput requirements, the retry wrapper above handles it, but benchmark before committing to this approach, and size the base delay to your transaction's actual latency. When we tested ten concurrent checkouts against a single hot row over a network connection, a 10ms base delay was too aggressive and most writers exhausted their five retries; at a 100ms base delay (roughly the round-trip time of one transaction) all ten succeeded.

#Current limitations and what's coming

Concurrent writes on Turso Cloud are an early preview. The team ships it with that label intentionally: this is for evaluation and experimentation, not production workloads. Not yet.

CREATE INDEX and other DDL statements are not supported inside BEGIN CONCURRENT transactions. If you need to create an index, do it in a regular BEGIN transaction. This is a known limitation of the current MVCC implementation.

It requires explicit opt-in. You have to enable concurrent writes in the dashboard, create a tursodb database (not the default SQLite type), and use BEGIN CONCURRENT in your transactions. Nothing happens automatically.

The engineering deep dive covers each of these limitations in detail and explains what's on the roadmap to address them.

#When to use this, and when not to

BEGIN CONCURRENT targets the specific case where SQLite's simplicity, embeddability, and economics make sense for your application, but the single-writer model was the blocker. Multi-tenant SaaS where each tenant gets an isolated database, real-time ingestion pipelines writing across distinct rows, AI agent backends where concurrent operations need to commit independently are workloads where the tradeoff pays off.

If your writes are sequential, single-threaded, and fast, you don't need this. If your workload hammers a single hot row from dozens of concurrent writers, MVCC won't save you from the fundamental constraint. You'll still need to redesign the access pattern.

Try it: enable concurrent writes in the Turso Cloud dashboard, create a tursodb database, and run your first BEGIN CONCURRENT transaction. If you hit something unexpected, the Discord is the fastest way to get answers. And if you want to dig into the engine itself, the Turso Database repo is MIT-licensed and open to contributions.