A SQLite Write-Ahead Log Lock Wasted One Team’s Monthly Cassandra Cluster Budget
A 12-node Cassandra cluster, each node costing roughly $1,000 per month, existed for one reason: to absorb write spikes from a single microservice. The infrastructure team at a mid-size SaaS firm—call it EventStream Analytics—had scaled the cluster three times in the preceding year, each time assuming traffic growth justified the expense. But the real culprit was not increased load. It was a SQLite write-ahead log lock in a sidecar process that serialized writes under moderate throughput, triggering retry storms that cascaded into the central database. The post-mortem, published internally and later shared at a local meetup, became a case study in how architectural overkill can mask a cheap code problem.
The $12,000-per-Month Cassandra Cluster That Shouldn’t Have Been Needed
The cluster ran Cassandra 4.0 on r5.xlarge instances across three availability zones. Each node carried roughly 500 GB of SSD storage. The monthly cost, including data transfer and backup snapshots, consistently landed near $12,000. The team had inherited the cluster from a predecessor who believed Cassandra’s write scalability would future-proof the system. Instead, it became a cost center that grew linearly with every new microservice instance deployed.
The workload was deceptively simple. A single service—the event-ingestion service—received telemetry data from client applications. It needed to buffer events briefly before persisting them to long-term storage. The service ran as a set of stateless containers, each with a local SQLite sidecar that held a small queue of pending events. Under normal load, the sidecar flushed events to Cassandra every few seconds. The architecture seemed clean: SQLite for ephemeral buffering, Cassandra for durable storage. But the design assumed SQLite could handle concurrent writes from multiple worker threads without significant contention.
By late 2023, the team noticed that Cassandra write latencies climbed from a baseline of roughly 2 milliseconds to over 500 milliseconds during peak hours. Compaction logs showed no unusual activity. CPU on Cassandra nodes hovered around 30 percent. The cluster was not saturated; it was being hammered by retries. Every time the sidecar failed to write to Cassandra within a 200-millisecond timeout, it retried immediately. Under moderate write load—roughly 200 to 400 writes per second per instance—the retries amplified the actual write volume by a factor of three to five. The cluster scaled up to 12 nodes not because it needed the storage, but because it needed enough request-handling capacity to absorb the retry storms. The team had essentially built a distributed system to compensate for a local lock.
How a SQLite WAL Lock Serialized Writes Under Moderate Load
SQLite’s write-ahead log (WAL) mode improves concurrency by allowing readers to proceed while a writer appends to the log. But the WAL mode uses a shared memory lock—the WAL-index lock—to coordinate access to the log. When a writer commits, it must acquire this lock briefly. Under single-threaded access, the lock is almost never contended. The sidecar, however, used eight worker threads that all wrote to the same SQLite database concurrently. Each thread performed a small INSERT, then waited for the commit to complete before fetching the next event. With eight threads contending for the same lock, the database spent an increasing fraction of time in lock acquisition.
The problem grew non-linearly. At low write rates—say, 50 writes per second—the lock was free most of the time, and commit latency stayed under a millisecond. At 200 writes per second, the probability of a thread finding the lock held by another thread rose sharply. Threads began to spin or sleep-wait, adding tens of milliseconds to each write. Worse, SQLite’s checkpoint operation, which moves pages from the WAL file back into the main database file, blocks all writers for the duration of the checkpoint. On the sidecar’s modest storage, a checkpoint could take 150 to 200 milliseconds. During that window, every incoming write queued behind the checkpoint lock, amplifying latency for all workers.
The team measured database response times from the sidecar’s perspective. Under peak load, the 99th percentile commit latency exceeded 100 milliseconds—a 100x increase over the sub-millisecond baseline. The sidecar’s retry logic, which treated any write taking longer than 200 milliseconds as a failure, began to trigger frequently. But the retries did not alleviate the lock contention; they only added more writes to the same lock queue. The sidecar was effectively choking on its own retries.
The Sidecar Pattern That Magnified the Lock Contention
The sidecar pattern itself was not unusual. Many microservice architectures use a local store—SQLite, RocksDB, or a simple file—to buffer data before forwarding it to a central system. The pattern reduces dependency on the central database during transient failures and smooths out write bursts. But the design choices inside the sidecar matter enormously. This team’s sidecar used synchronous writes: each worker thread called SQLite’s INSERT, waited for the commit, and only then acknowledged the event as received. The intent was to guarantee durability before the event left the service boundary. In practice, it turned the SQLite WAL lock into a global bottleneck for all worker threads.
The retry logic compounded the problem. When a write to Cassandra failed or timed out, the sidecar placed the event back into the local queue and retried after a fixed 100-millisecond delay. No exponential backoff, no jitter. Under normal conditions, the retry queue remained empty. But once lock contention pushed local write latencies above 100 milliseconds, the retry queue grew. Each retry consumed another SQLite write slot, further congesting the lock. The sidecar entered a feedback loop: high lock contention caused retries, retries increased write volume, increased write volume worsened lock contention. Within minutes, the sidecar could generate three times the original write load.
Cassandra, designed for high write throughput, handled the amplified load without complaint—up to a point. Each node could sustain several thousand writes per second. But the retry storms were bursty, arriving in waves that coincided with checkpoint events across multiple sidecar instances. When ten sidecars simultaneously checkpointed, the resulting retry wave could exceed 5,000 writes per second to Cassandra, pushing latency past the 500-millisecond mark. The team responded by adding more Cassandra nodes, spreading the load, and temporarily masking the underlying cause. Each node addition cost roughly $1,000 per month. By the time the post-mortem began, the cluster had grown to 12 nodes.
Post-Mortem Evidence: Heap Dumps, Flame Graphs, and Log Timestamps
The post-mortem started with a flame graph generated from a production sidecar instance. The graph showed that roughly 70 to 80 percent of worker thread CPU time was spent in SQLite lock acquisition functions—specifically in sqlite3_step and sqlite3VdbeHalt, the internal routines that handle commit and lock release. The remaining time was split between network I/O and event processing. The engineers had seen similar profiles before but attributed them to disk I/O. The flame graph made the lock contention unmistakable.
A heap dump of the sidecar process revealed thousands of pending write futures, each representing an event waiting to be inserted into SQLite. The futures were held in an in-memory queue that had grown to over 10,000 entries during peak traffic. The queue itself consumed roughly 200 MB of heap, but more importantly, it indicated that the sidecar was accepting events faster than it could write them to SQLite. The events were piling up in memory, waiting for the WAL lock to become available. The heap dump also showed multiple retry futures, each scheduled to fire after a 100-millisecond delay, compounding the backlog.
Log timestamps told the story in plain text. The team correlated sidecar commit logs with Cassandra write latency logs. Every time a sidecar checkpoint started—visible as a log line like checkpoint begin—Cassandra latency for that instance’s writes spiked within 200 milliseconds. The correlation coefficient was near 0.9. Checkpoint operations themselves lasted 150 to 200 milliseconds, during which no new writes could commit. The sidecar’s retry logic then fired, and Cassandra saw a burst of writes from that instance roughly 100 milliseconds after the checkpoint ended. The pattern repeated every few minutes across all instances, creating a distributed thundering herd.
Three Fixes That Eliminated the Cassandra Cluster Entirely
The first fix replaced SQLite with an in-memory ring buffer. The sidecar no longer persisted events to disk locally. Instead, it held events in a fixed-size circular buffer in the application process. When the buffer reached a configurable high-water mark—say, 80 percent full—the sidecar flushed all buffered events to the central store in a single batch. This eliminated SQLite entirely, along with its WAL lock contention. Write latency dropped from over 100 milliseconds to under 1 millisecond because the buffer was just a memory write. The sidecar could now handle thousands of writes per second without serialization.
The second fix targeted the retry logic. The team replaced the fixed 100-millisecond retry delay with exponential backoff capped at 500 milliseconds, plus random jitter. If a write to Cassandra failed, the sidecar waited 50 milliseconds, then 100, then 200, up to 500. Jitter ensured that multiple sidecar instances did not retry in lockstep. The retry volume dropped by roughly 80 percent under peak load. The remaining retries were spread out enough that Cassandra never saw a burst exceeding 1,000 writes per second.
The third fix was the most surprising: the team removed Cassandra entirely. With the ring buffer absorbing bursts and retries under control, the central store no longer needed Cassandra’s write scalability. The team replaced it with a single Redis instance configured with append-only file persistence. Redis handled the remaining write load—roughly 200 to 400 writes per second sustained—with sub-millisecond latency. The monthly infrastructure cost dropped from $12,000 to roughly $800 to $1,200, depending on Redis instance size and backup storage. The team documented the entire journey in an internal engineering blog post titled “How We Killed a Cassandra Cluster with Three Code Changes.”
But this outcome is not universally applicable. For workloads that require multi-region replication, strong consistency across data centers, or massive write scalability, Cassandra remains a solid choice. The team’s workload was a simple queue: events needed to be persisted and later consumed by a batch job. A single Redis instance, or even a PostgreSQL table with a proper index, would have sufficed. The $12,000 cluster was a symptom of a $0 code problem—a lock contention issue that cost nothing to fix but thousands to work around. The team’s post-mortem is now cited internally as a cautionary tale: before scaling the database, check whether the bottleneck is in the application layer. Sometimes the cheapest fix is also the most effective.
Reasonable engineers might disagree with the choice to abandon SQLite entirely. Some have argued that tuning SQLite’s PRAGMA settings—increasing wal_autocheckpoint, reducing journal_size_limit, or using PRAGMA synchronous=OFF—could have mitigated the lock contention without replacing the store. That approach might have worked for workloads with lower durability requirements. But the team needed guaranteed persistence for telemetry events, and turning off synchronous commits risked data loss. The ring buffer trade-off—losing data if the process crashes before a flush—was acceptable because the upstream services could replay events. Each team must weigh its own durability needs against latency and cost. The lesson is not which store to use, but how to detect and measure the real bottleneck before spending money on more hardware.
Lessons for Teams Building Sidecar-Based Microservice Architectures
The story is not an indictment of SQLite. SQLite remains an excellent choice for single-writer scenarios, embedded databases, and read-heavy workloads. But it performs poorly under multi-threaded write contention, especially when the write rate exceeds a few hundred operations per second. The WAL mode improves read concurrency but does not eliminate the write serialization bottleneck. Teams using SQLite as a sidecar store should measure lock contention under realistic load before scaling the downstream database. A simple benchmark with sysbench or a custom script can reveal whether the local store becomes the bottleneck.
The sidecar pattern itself deserves scrutiny. Many teams adopt it because it decouples services from the central database and provides a local cache for frequently accessed data. But the pattern introduces complexity: data consistency between the sidecar and the central store, failure recovery if the sidecar crashes before flushing, and monitoring of queue depths. The team in this case neglected to monitor the SQLite queue depth until the heap dump revealed it. A simple metric—number of pending events in the sidecar—would have alerted them months earlier. As we discussed in a previous article on cache invariants, the absence of observability often masks the real bottleneck.
Backpressure mechanisms are critical. The original sidecar had none. It accepted events from upstream services regardless of its internal queue depth. When the queue grew, the sidecar continued accepting events, pushing the problem downstream into retries. A proper backpressure mechanism—such as returning a 429 Too Many Requests status when the queue exceeds a threshold—would have protected both the sidecar and Cassandra. The upstream service could then slow down or shed load, preventing the feedback loop. The team’s ring buffer fix implicitly added backpressure: when the buffer filled, new writes blocked until space freed up. But explicit backpressure at the API level would have been cleaner.
Not every team will eliminate Cassandra. For workloads that require multi-region replication, strong consistency across data centers, or massive write scalability, Cassandra remains a solid choice. But this team’s workload was a simple queue: events needed to be persisted and later consumed by a batch job. A single Redis instance, or even a PostgreSQL table with a proper index, would have sufficed. The $12,000 cluster was a symptom of a $0 code problem—a lock contention issue that cost nothing to fix but thousands to work around. The team’s post-mortem is now cited internally as a cautionary tale: before scaling the database, check whether the bottleneck is in the application layer. Sometimes the cheapest fix is also the most effective.
Reasonable engineers might disagree with the choice to abandon SQLite entirely. Some have argued that tuning SQLite’s PRAGMA settings—increasing wal_autocheckpoint, reducing journal_size_limit, or using PRAGMA synchronous=OFF—could have mitigated the lock contention without replacing the store. That approach might have worked for workloads with lower durability requirements. But the team needed guaranteed persistence for telemetry events, and turning off synchronous commits risked data loss. The ring buffer trade-off—losing data if the process crashes before a flush—was acceptable because the upstream services could replay events. Each team must weigh its own durability needs against latency and cost. The lesson is not which store to use, but how to detect and measure the real bottleneck before spending money on more hardware.