Himetrica Himetrica Blog
9 min read

Database Challenges Nobody Warns You About When Scaling

Scaling a database sounds straightforward until you actually try it. Here's an honest look at the problems that show up when your data grows — and the tradeoffs behind every solution.

engineering databases scaling infrastructure
Database Challenges Nobody Warns You About When Scaling

There’s a moment in every product’s life when the database starts fighting back.

Things that used to take 20 milliseconds now take 2 seconds. A query that worked fine with 10,000 rows becomes a disaster at 10 million. Your staging environment runs flawlessly while production falls over during peak hours.

If you’ve been there, or you’re on your way there, this post covers the real challenges that come with scaling a database, the tradeoffs you’ll face, and why there’s no single right answer.

The “just add an index” phase

The first scaling problem almost everyone hits is slow queries. And the first instinct is almost always the same: add an index.

Sometimes that works. A missing index on a frequently queried column can turn a 3-second table scan into a 5-millisecond lookup. It feels like magic.

But indexes aren’t free.

Every index you add slows down writes, because inserts and updates now need to update the index too. It consumes storage at a rate that surprises most people. A heavily indexed table can have indexes larger than the data itself. And it complicates the query planner, giving it more choices to evaluate, and the planner doesn’t always pick the right one.

The real skill isn’t knowing how to add indexes. It’s knowing when to stop.

At some point, you’re trading write performance for read performance, and you need to make that tradeoff consciously rather than just throwing indexes at every slow query.

When your queries work against you

As your data grows, queries that were perfectly fine at launch become problems.

Close-up of network cables connected to a server, symbolizing data flow and connectivity

The N+1 trap

You fetch a list of 100 items, then for each item, fire a separate query to load related data. That’s 101 queries instead of 1 or 2. At small scale, the overhead is invisible. At large scale, it’s a death spiral.

The fix seems obvious: use joins or batch loading. But in practice, N+1 queries are sneaky. They hide inside ORMs, they emerge from lazy-loaded relationships, and they’re easy to introduce without realizing it. You need monitoring to catch them, not just code reviews.

Unbounded queries

SELECT * FROM events WHERE project_id = 123 is fine when a project has 500 events. It’s catastrophic when they have 5 million.

Every query that touches user-generated or time-series data needs to be bounded by time range, by pagination, by some limit. The tricky part is retrofitting this. Your API returns all results? Now you need pagination. Your dashboard shows “all time” by default? Now you need a default time range.

These are product decisions hiding inside engineering problems.

Aggregations that don’t scale

Counting, summing, and averaging across large datasets gets expensive fast. A COUNT(*) that scans millions of rows on every page load will bring your database to its knees.

The options are all tradeoffs. You can pre-compute and cache, which gives you fast reads but your numbers can be stale. You can use materialized views, where the database handles the caching, but refresh frequency is a balancing act. You can try approximate counts, which are good enough for most dashboards but unacceptable for billing. Or you can move to a dedicated analytics store, which solves the problem but adds operational complexity.

There’s no clean answer here. You’ll probably end up with a mix of all four.

The connection pool wall

Databases have a maximum number of concurrent connections. Hit that limit, and new requests start queuing or failing.

This catches people off guard because the database itself might be underutilized. It’s the connections, not the compute, that become the bottleneck.

Long-running transactions hold connections for seconds instead of milliseconds, tying up resources. Connection leaks happen when you open connections without properly closing them, especially in error paths. And too many application instances compound the problem: 20 instances with 10 connections each is already 200 connections.

Connection poolers like PgBouncer or ProxySQL can help, but they introduce their own complexity. Transaction-level vs session-level pooling, prepared statement compatibility, and another piece of infrastructure to monitor and maintain.

Reads vs. writes: the fundamental tension

At some point, you’ll realize that reads and writes have fundamentally different scaling characteristics.

A modern data center hallway with rows of server racks and blue LED lighting

Reads scale horizontally. You can add read replicas, put a cache in front of the database, serve stale data when it’s acceptable. There are plenty of levers to pull.

Writes are harder. Every write has to go through the primary. You can batch them, queue them, partition the data, but ultimately, there’s a ceiling on how fast a single database node can process writes.

This is where architectural decisions start to matter more than query optimization.

Read replicas are great for reporting and dashboards, but replication lag means you might read stale data right after a write. Caching layers dramatically reduce database load, but cache invalidation is famously one of the hardest problems in computer science. Write-ahead patterns accept the write quickly and process it asynchronously, but now you’re dealing with eventual consistency and your users might not see their data immediately.

Each of these is a tradeoff between consistency, latency, and complexity. The right choice depends on what your users can tolerate.

Migrations on a live database

Schema changes are easy when your table has 1,000 rows. They’re terrifying when it has 100 million.

Adding a column? On some databases, that locks the entire table for the duration of the migration. On a table with hundreds of millions of rows, “duration” might mean minutes. Minutes of your application being unable to write to that table.

The safe approaches all add complexity. Online schema change tools create a shadow copy of the table, sync changes, then swap. It works, but the table temporarily uses double the storage. Multi-phase migrations mean you add the column as nullable first, backfill data, then add the constraint. Three deploys instead of one. Application-level versioning has your code handle both the old and new schema simultaneously during the transition. Messy, but necessary.

The lesson is that database migrations need to be treated as first-class operations with their own rollback plans, not afterthoughts tacked onto a deploy.

The replication lag problem

Once you add read replicas, you get replication lag. And replication lag creates the most confusing bugs.

Picture this: a user updates their profile, gets redirected to their profile page, and sees the old data. The write went to the primary, but the read came from a replica that hasn’t caught up yet.

Read-your-own-writes forces subsequent reads from the primary after a write for that user. Effective, but adds routing complexity. Sticky sessions pin a user to the primary for a short window after writes. Simple, but reduces the benefit of read replicas. Causal consistency tokens are the most correct solution, but also the most complex to implement.

In practice, most teams start with sticky sessions and move to something more sophisticated as they scale. The perfect solution isn’t worth building if a good-enough solution handles your current load.

Partitioning: simple in theory, messy in practice

When a single table gets too large, the textbook answer is partitioning. Split the data into smaller chunks based on some key.

Time-based partitioning is the most common: one partition per month, per week, per day. It works beautifully for time-series data like logs, events, and analytics. Old partitions can be archived or dropped efficiently.

But partitioning introduces its own headaches. Cross-partition queries get expensive. A query that spans many partitions can be slower than querying a single large table. Choosing the wrong partition key means your queries don’t align with your partition boundaries, and you get the worst of both worlds. And there’s maintenance overhead: creating new partitions, managing retention policies, monitoring partition sizes, dealing with ORMs that don’t handle partitioned tables gracefully.

The decision to partition should be driven by actual problems, not anticipated ones. Premature partitioning adds complexity without benefit.

Data modeling decisions that haunt you

The schema you design on day one will shape your scaling story for years. Some decisions are much harder to change later.

Developer working at a computer with multiple monitors showing code

UUIDs vs. sequential IDs

UUIDs are great for distributed systems and avoiding ID collisions. But they’re terrible for index performance because they’re random. Inserts scatter across the B-tree instead of appending to the end, which at scale significantly increases write amplification and index fragmentation.

Sequential IDs are efficient but leak information (users can guess how many records exist) and don’t work well across distributed systems. There are middle grounds like ULIDs or UUID v7 that give you uniqueness with time-ordering. But they require you to think about this upfront.

Normalization vs. denormalization

Normalized data is clean, consistent, and space-efficient. It’s also slow to query when you need to join five tables to render a single page.

Denormalized data is fast to read but painful to update. Change a user’s name and you might need to update it in 15 different places.

Most production systems end up somewhere in between: normalized for writes, denormalized (via caching, materialized views, or read models) for reads.

The JSON column trap

Storing flexible data as JSON inside a relational database feels liberating at first. No migrations needed! Schema-free!

But at scale, you can’t efficiently index deeply nested JSON fields. Query performance degrades as the JSON grows larger. You lose type safety and referential integrity. Debugging becomes harder when the structure isn’t enforced.

JSON columns are fine for truly unstructured metadata. They’re a footgun for data you’ll need to query, filter, or aggregate.

Monitoring: you can’t fix what you can’t see

The single most impactful thing you can do for database scaling is set up proper monitoring before you need it.

At minimum, you should be tracking query execution time at p50, p95, and p99 (not just averages). Keep a slow query log to catch problems before users report them. Watch your connection pool usage so you know when you’re approaching limits. If you have replicas, monitor replication lag. Track table sizes and growth rates so partitioning or archiving decisions aren’t surprises. And keep an eye on lock contention, because deadlocks and long-held locks are often the hidden cause of “the database is slow.”

Most scaling emergencies could have been prevented, or at least anticipated, with basic monitoring in place.

There’s no “just scale the database”

The biggest misconception about database scaling is that it’s a single decision. “We need to scale the database” sounds like one task. In reality, it’s a long series of tradeoffs, each one depending on your specific data patterns, query shapes, consistency requirements, and tolerance for complexity.

What works for a time-series analytics workload won’t work for a transactional e-commerce system. What makes sense at 1 million rows might be wasteful at 10,000 and insufficient at 1 billion.

The teams that scale their databases well aren’t the ones with the fanciest architecture diagrams. They’re the ones who:

  1. Measure before they optimize. They know where the actual bottleneck is, not where they think it might be.
  2. Make tradeoffs consciously. Every optimization has a cost, and they know what they’re paying.
  3. Keep things simple as long as possible. Adding complexity is easy, removing it is not.
  4. Plan for migration paths. Not just “what do we need now” but “how do we get from here to there without downtime.”

Start with the simplest thing that works. Add complexity only when the data tells you to. And always, always have a rollback plan.

H

Himetrica Team

Published on February 18, 2026