Karya Semi
HomeBlogSearchCategoriesAboutContact
Karya Semi

Less noise. More notes.

HomeBlogAboutContactPrivacy PolicyDisclaimer

© 2026 Karya Semi. All rights reserved.

XGitHubLinkedIn
  1. Home
  2. /Categories
  3. /Technology

Optimizing P99 Database Latency: Techniques Beyond Simply Adding Indexes

Fix tail latency in production databases by resolving buffer pool pollution, lock contention, micro-batching issues, and connection pooling bottlenecks.

Dian Rijal Asyrof/August 6, 2026/6 min read
Illustration for Optimizing P99 Database Latency: Techniques Beyond Simply Adding Indexes

Most developers react to slow database queries by running EXPLAIN and slapping an index on the table. It works for the average request. If your P50 latency is creeping up, a missing index is usually the culprit. But when your P99 latency spikes-meaning one percent of your users experience lag that feels like a dial-up connection-indexes rarely help.

Tail latency is a different beast. It is rarely about how fast the database can read a row off the disk. It is about how long a query has to wait in line before it even gets to try. When you hit the 99th percentile, you are looking at resource contention, queueing delays, and background maintenance tasks clashing with user traffic. For example, running schema updates without a proper production database migration strategy can lock tables and degrade performance for all users.

Buffer Pool Pollution and Cache Eviction

Databases rely on memory caching to stay fast. Postgres uses shared buffers; MySQL uses the InnoDB buffer pool. These memory areas keep hot data ready for instant access.

The trouble starts when you run a query that does not fit in memory. Imagine a nightly reporting script or a poorly written search query that scans a massive table. The database engine needs room to process this scan, so it starts evicting your hot data pages to make space.

Suddenly, the active user queries that normally execute in under five milliseconds are forced to hit physical disk storage. Disk reads are orders of magnitude slower than RAM. Your P99 latency goes through the roof while the buffer pool recovers.

The Mechanics of Cache Eviction

Databases use variants of the Least Recently Used (LRU) algorithm to manage memory. When a query requests data not in memory, the database reads it from disk and writes it to the buffer pool. If the pool is full, the database evicts the oldest page.

A single sequential scan of a 50GB table on a machine with 32GB of RAM will evict the entire cache. Postgres has some protections against this, like using a small ring buffer for sequential scans. But index scans that are slightly too large can still bypass this protection and pollute the cache.

How to Diagnose and Fix It

You can spot this by tracking your buffer cache hit ratio. If it hovers around 99% and then plunges to 85% during specific windows, you have a pollution problem.

Fixing this requires isolating your workloads. Do not run analytical queries on your primary transactional database. Route them to a read replica. If you must run large scans on the primary, configure your application to use cursor-based pagination or batching to avoid loading millions of rows at once.

Dirty Page Flushing and Write Amplification

Writing data to a database is a two-step process. First, the database writes the change to the Write-Ahead Log (WAL) or transaction log. This is a fast, sequential write. Second, it updates the data page in memory. The page in memory is now "dirty" because it differs from the version on disk.

Eventually, these dirty pages must be written back to the data files on disk. This process is called checkpointing or flushing.

If your application write volume is high, dirty pages accumulate fast. When the database hits its dirty page limit, it triggers an aggressive flushing cycle. MySQL, for example, uses background threads to flush pages. But if the queue gets too long, the user threads writing data are forced to help flush pages to disk.

The Write Amplification Problem

This causes sudden, massive latency spikes. A query that usually takes two milliseconds suddenly takes two seconds because it is blocked waiting for disk writes to complete. This is made worse by write amplification. If you change a single byte in an 8KB page, the database must write the entire 8KB page to disk.

To diagnose this, monitor your disk write I/O queue length and checkpoint activity. If you see write latency spikes that align with checkpoint events, your flushing configuration is too conservative.

Tuning the Flush Rates

You can tune this by adjusting how aggressively the database flushes pages in the background. In MySQL, you can increase innodb_io_capacity to match your SSD's actual performance. You can also adjust innodb_max_dirty_pages_pct_lwm to start flushing earlier and more gradually. In Postgres, tuning max_wal_size and checkpoint_completion_target spreads the write load over a longer period, preventing massive write spikes.

Lock Contention and the API Call Trap

Locking is how databases guarantee consistency, but mismanaging database locking mechanisms is also the primary source of queueing delay.

A common pattern that destroys P99 latency is holding database locks while performing slow, external operations. Let us look at a typical checkout flow:

- Application starts transaction
BEGIN;
 
- Select user account and lock the row
SELECT balance FROM accounts WHERE user_id = 42 FOR UPDATE;
 
- Application calls external payment gateway API (takes 2 seconds)
 
- Application updates the account balance
UPDATE accounts SET balance = balance - 10 WHERE user_id = 42;
 
COMMIT;

If the payment gateway API takes three seconds to respond, that user row remains locked for three seconds. Any other request trying to update that user's account will block. If your payment provider experiences a minor slowdown, your database connection pool fills up with blocked transactions, causing a cascading failure across the entire application.

Keep External Calls Out of Transactions

Keep external API calls out of database transactions. The correct flow is:

  1. Create a pending transaction record.
  2. Commit the transaction immediately.
  3. Call the payment gateway outside the database transaction context.
  4. Start a new transaction to update the status to completed.

If you need to coordinate multiple steps, write events to an outbox table within the local transaction. Then use a background worker to read the table and publish the events asynchronously. This keeps database transactions short, reducing lock hold times to milliseconds.

Connection Pool Bloat and Thread Contention

Many teams believe that more database connections equal better throughput. They configure their application connection pools to allow hundreds or thousands of concurrent connections.

This is a mistake. Each connection requires the database to allocate memory and manage a thread or process. When you have hundreds of active threads competing for a limited number of CPU cores, the operating system spends more time switching between threads than doing actual work. This is context switching overhead.

Postgres is particularly sensitive to this because it uses a process-based model. Every connection is a separate OS process.

The Math of Connection Sizing

When your application experiences a traffic spike, the connection pool fills up. The database spends all its CPU capacity managing connection handshakes and context switching. Latency skyrockets, and the database appears completely unresponsive.

The solution is to use a connection pooler and keep the pool size small. For Postgres, PgBouncer is the standard tool. Run it in transaction pooling mode. This allows thousands of application clients to share a tiny pool of actual database connections.

A good rule of thumb for database connection pool sizing is:

Connections = ((CPU cores * 2) + Effective Spindle Count)

For a server with 8 CPU cores and an SSD, a pool size of 17 to 20 connections is often enough to maximize throughput. If you need more, queue the requests in the application layer, not inside the database.

Micro-Batching and WAL Bottlenecks

When you need to insert thousands of rows, doing them one by one is slow. Each individual insert requires a round-trip network call, a transaction start, a write to the WAL, and a commit confirmation. This is also true for large-scale deletions, such as those required to comply with California's DROP data deletion enforcement, which can overwhelm the transaction log if executed in a single block.

Some developers try to fix this by using micro-batching. They group inserts into batches of 50 or 100.

While this improves throughput, it can introduce tail latency spikes if not handled carefully. Writing to the WAL requires a lock. If multiple application threads are trying to commit large micro-batches simultaneously, they will block each other at the WAL write stage.

Batching vs Bulk Operations

If you are dealing with high-throughput ingestion, look at group commits. Databases like PostgreSQL and MySQL support grouping multiple commits into a single disk write.

You can also look at asynchronous commits if your application can tolerate losing a few milliseconds of data in a crash. In Postgres, setting synchronous_commit = off tells the database to report success to the client as soon as the transaction is written to memory, before it is flushed to the physical WAL disk. This drastically reduces write latency at the cost of potential data loss during a sudden power failure.

Diagnosing Tail Latency

You cannot fix what you cannot measure. Standard monitoring tools that show average CPU usage or average query latency will miss P99 spikes entirely.

You need tools that capture query performance distributions. Use Postgres's pg_stat_statements extension or MySQL's Performance Schema.

Look for queries with high maximum execution times relative to their mean. If a query has a mean execution time of 2 milliseconds but a max execution time of 5 seconds, that is your P99 culprit.

Enabling Slow Query Logs

Next, enable log settings that capture slow queries. In Postgres, set log_min_duration_statement to a reasonable threshold, like 250 milliseconds. You should also enable log_lock_waits to log transactions that wait longer than deadlock_timeout for a lock.

Once you have the data, check if they align with disk write spikes, checkpoint events, or high connection counts.

Optimizing tail latency requires looking past the individual query syntax. It forces you to understand how the database interacts with the underlying operating system, disk I/O subsystem, and memory architecture. Stop looking for missing indexes and start looking at system bottlenecks.

DR

Dian Rijal Asyrof

Writes about useful AI tools, programming practice, and the craft of building reliable software.

Previous articleZero-Downtime Schema Migrations: Designing PostgreSQL Databases for Continuous DeploymentNext articleZero-Knowledge State Channels: The Scalability Bridge DeFi Desperately Needs
DatabasesPerformanceBackendSystem Architecture
On this page↓
  1. Buffer Pool Pollution and Cache Eviction
  2. The Mechanics of Cache Eviction
  3. How to Diagnose and Fix It
  4. Dirty Page Flushing and Write Amplification
  5. The Write Amplification Problem
  6. Tuning the Flush Rates
  7. Lock Contention and the API Call Trap
  8. Keep External Calls Out of Transactions
  9. Connection Pool Bloat and Thread Contention
  10. The Math of Connection Sizing
  11. Micro-Batching and WAL Bottlenecks
  12. Batching vs Bulk Operations
  13. Diagnosing Tail Latency
  14. Enabling Slow Query Logs

On this page

  1. Buffer Pool Pollution and Cache Eviction
  2. The Mechanics of Cache Eviction
  3. How to Diagnose and Fix It
  4. Dirty Page Flushing and Write Amplification
  5. The Write Amplification Problem
  6. Tuning the Flush Rates
  7. Lock Contention and the API Call Trap
  8. Keep External Calls Out of Transactions
  9. Connection Pool Bloat and Thread Contention
  10. The Math of Connection Sizing
  11. Micro-Batching and WAL Bottlenecks
  12. Batching vs Bulk Operations
  13. Diagnosing Tail Latency
  14. Enabling Slow Query Logs

See also

Illustration for Understanding Database Locks: Row, Page, and Table Level Mechanics
Software Engineering/Jul 20, 2026

Understanding Database Locks: Row, Page, and Table Level Mechanics

A practical breakdown of database locking mechanisms, isolation levels, and lock escalation to prevent deadlocks and performance degradation in production.

5 min read
DatabaseBackend
Illustration for Zero-Downtime Schema Migrations: Designing PostgreSQL Databases for Continuous Deployment
Technology/Aug 6, 2026

Zero-Downtime Schema Migrations: Designing PostgreSQL Databases for Continuous Deployment

Deploy schema changes to your production postgresql database without locking tables. Master safe column additions, type alterations, and index building.

7 min read
PostgresqlDatabases
Illustration for Branchless Rust: Accelerating Data Filters by Eliminating Conditionals
Programming/Aug 6, 2026

Branchless Rust: Accelerating Data Filters by Eliminating Conditionals

Branch prediction failures can slow down tight hot loops. A practical look at implementing branchless programming patterns in Rust to speed up filter functions.

4 min read
RustOptimization