Part III — The Database: Where Scalability Often Dies
"Your application may have thousands of users, but eventually every request has to ask the database for something."
In Part I, we established that scalable software begins with engineering decisions rather than expensive infrastructure.
In Part II, we moved one layer deeper and examined architecture: how systems are structured, how responsibilities are separated, and why the wrong architectural decisions can make future growth unnecessarily painful.
But architecture is only part of the story.
Eventually, almost every application reaches the same bottleneck.
The database.
You can optimize your frontend.
You can add more application servers.
You can introduce caching.
You can move workloads into queues.
You can increase CPU and memory.
But if every request eventually reaches a database that wasn't designed to handle the workload, the database becomes the ceiling for everything else.
And this is where many applications begin to struggle.
Not at 10,000 users.
Sometimes at 1,000.
Sometimes at 500.
Sometimes even earlier.
The problem is rarely that databases are inherently slow.
The problem is that developers often design databases for the amount of data they have today rather than the amount of data the business will eventually accumulate.
A table with 10,000 rows can hide a lot of mistakes.
A table with 10 million rows usually doesn't.
One of the earliest misconceptions developers encounter is thinking of the database as simply a place where application data is stored.
It is much more than that.
A database is responsible for answering questions.
Very quickly.
Your application constantly asks questions such as:
Every one of these questions becomes a database operation.
And as your application grows, the number of questions increases dramatically.
A system with 500 users might generate thousands of queries every hour.
A system with 10,000 users might generate millions.
The database doesn't care that your application is successful.
It simply receives more work.
This is why database design must be treated as part of application architecture rather than something developers configure after the application has already been built.
During development, almost every database feels fast.
You create a users table.
You insert 100 records.
You run:
SELECT * FROM users;The result appears immediately.
You create a transactions table.
You insert a few hundred transactions.
You run a query filtering by user ID.
Again, almost instant.
Everything feels perfect.
Then production happens.
The users table grows to hundreds of thousands of records.
The transactions table grows into millions.
Audit logs accumulate.
Notifications accumulate.
Activity history accumulates.
Payments accumulate.
And suddenly queries that used to execute in milliseconds begin taking seconds.
The code didn't necessarily change.
The data did.
This is one of the most important concepts in scalable database engineering:
A query that is fast with small data is not necessarily a query that scales.
Performance must be considered relative to data growth.
Before worrying about indexes or database servers, you need to get the schema right.
A schema is effectively the structural blueprint of your database.
It defines:
A poorly designed schema creates problems that become increasingly expensive to fix.
Consider a simple transaction table.
You might start with:
transactions
id
user_id
amount
status
created_atLooks simple.
But real systems quickly become more complicated.
What currency is the amount?
Can a transaction have multiple currencies?
What happens when a transaction is reversed?
Where is the provider reference stored?
What identifies the transaction externally?
Can two transactions have the same reference?
What happens when a payment provider retries the request?
What happens if the application crashes halfway through processing?
These aren't merely coding questions.
They are database design questions.
And the answers determine whether your system remains reliable when transaction volume increases.
Choosing a database column type isn't just about making the migration work.
It affects storage, comparison, indexing, and sometimes performance.
For example, storing monetary values as floating-point numbers can create precision problems.
Imagine:
0.1 + 0.2Mathematically, we expect:
0.3But floating-point representation can produce results that aren't exactly 0.3.
For financial systems, this matters.
Money should generally be represented using an appropriate exact numeric strategy, such as fixed-precision decimal values or integer minor units, depending on the system's requirements.
The same principle applies elsewhere.
Dates should use appropriate date/time types.
Boolean values should not be represented as arbitrary strings.
Large identifiers may require larger integer types.
JSON can be useful, but it shouldn't become an excuse to put an entire relational model inside one column.
Every data type is a design decision.
Database normalization is another concept developers often encounter early and then forget.
The basic idea is simple:
Don't unnecessarily duplicate the same piece of information across many records.
Imagine an orders table containing:
order_id
customer_id
customer_name
customer_email
customer_phone
product_id
product_name
priceAt first, this seems convenient.
But what happens when the customer's phone number changes?
Now multiple records may contain the old value.
What happens when the product name changes?
Now historical records may contain inconsistent information.
Relational databases provide a better approach.
Separate the concepts into appropriate tables and connect them through relationships.
For example:
users
products
orders
order_itemsNow each entity has a clearer responsibility.
However, normalization should also be applied thoughtfully.
Sometimes controlled denormalization is useful for performance, reporting, or read-heavy systems.
The goal isn't to follow database theory blindly.
The goal is to model the business accurately while understanding the trade-offs.
Relationships are one of the strengths of relational databases.
But relationships also introduce work.
Consider:
users
orders
order_items
productsA simple dashboard might need:
A developer may write a query joining all of these tables.
With a small dataset, it may work perfectly.
As the dataset grows, however, the database has significantly more work to perform.
This is why understanding joins is essential for backend developers.
You don't need to avoid joins.
You need to understand:
A join isn't automatically slow.
An uncontrolled join across massive datasets can be.
If there is one database concept every backend developer should understand deeply, it is indexing.
Imagine a library containing one million books.
You ask:
"Find every book written by Kingsley."
Without an organized catalog, someone may have to inspect every book.
That's essentially what happens when the database performs a full table scan.
An index provides a structure that helps the database locate relevant records without examining everything.
For example:
CREATE INDEX idx_users_email
ON users(email);Now a query such as:
SELECT *
FROM users
WHERE email = 'example@email.com';can potentially locate the relevant record far more efficiently.
But indexes are not free.
Every index consumes storage.
Every insert may require index updates.
Every update to an indexed column can require additional work.
Too few indexes can make reads painfully slow.
Too many indexes can make writes unnecessarily expensive.
Good database engineering is about finding the right balance.
A common mistake is creating indexes simply because a column "might be useful."
Indexes should be based on actual access patterns.
If your application frequently executes:
WHERE user_id = ?then user_id may need an index.
If your application frequently executes:
WHERE status = ?
AND created_at > ?a composite index may be appropriate.
The important word is frequently.
Database indexes should support real queries.
Don't create dozens of indexes without understanding why they exist.
Sometimes a query filters by multiple columns.
For example:
SELECT *
FROM transactions
WHERE user_id = ?
AND status = ?
ORDER BY created_at DESC;An index involving multiple columns may be more useful than separate indexes on each individual column.
For example:
(user_id, status, created_at)But composite indexes have ordering rules.
The order of columns matters.
An index isn't simply a bag containing several columns.
The database organizes the index according to its structure.
This is why experienced developers don't just add indexes randomly.
They examine the actual queries.
One of the most common performance problems in application development is the N+1 query problem.
Imagine you want to display 100 orders.
You run one query:
SELECT * FROM orders;Then your application loops through those 100 orders and fetches the customer for each one.
Instead of one query, you might end up with:
1 query for orders
+
100 queries for customers
=
101 queriesWith 10 orders, this might go unnoticed.
With 10,000 records, it becomes disastrous.
Modern frameworks provide tools for avoiding this problem.
For example, Laravel's eager loading:
Order::with('user')->get();can retrieve related records more efficiently than repeatedly querying the database inside a loop.
The lesson isn't specifically about Laravel.
The lesson is:
Don't let application loops silently turn one database operation into hundreds or thousands.
Another small habit that becomes expensive at scale is:
SELECT *It is convenient.
It is also often unnecessary.
Suppose your table contains:
id
name
email
phone
address
profile_photo
biography
metadata
created_at
updated_atBut your API only needs:
id
name
emailWhy retrieve everything?
Instead:
SELECT id, name, email
FROM users;This reduces unnecessary data transfer and can reduce memory usage.
It becomes particularly important when tables contain large text fields, JSON data, or other expensive columns.
Fetch what you need.
Not everything that exists.
Imagine an API endpoint returning all transactions.
During development:
500 transactionsNo problem.
After a year:
5,000,000 transactionsNow imagine returning all five million records in a single response.
Even if the database can execute the query, the application server has to process the data.
The network has to transfer it.
The client has to receive it.
The client may have to render it.
This is why pagination matters.
Instead of:
GET /transactionsreturning everything, you might support:
GET /transactions?page=1&per_page=50Modern systems may also use cursor-based pagination.
For very large datasets, cursor pagination can be more efficient than repeatedly calculating large offsets.
The important principle is simple:
Never make the database and API return millions of records simply because the client asked for "everything."
Consider:
SELECT *
FROM transactions
ORDER BY id
LIMIT 50 OFFSET 500000;The database may still need to work through a large number of rows before reaching the requested offset.
For large datasets, cursor-based approaches can be more efficient.
For example:
GET /transactions?after=982734The application can then retrieve records after a known position.
This approach becomes especially useful for feeds, transaction histories, logs, and other continuously growing datasets.
Scalability isn't only about speed.
It is also about correctness.
Imagine transferring money between two accounts.
You might need to:
What happens if step 1 succeeds but step 2 fails?
Now the system is inconsistent.
Database transactions exist to help handle operations that must succeed or fail together.
Conceptually:
BEGIN
Debit Account A
Credit Account B
Create Transaction Record
COMMITIf something goes wrong:
ROLLBACKThe exact implementation depends on the database and application architecture, but the principle is universal.
A scalable system must not simply process more transactions.
It must process them correctly.
Two requests can arrive at almost exactly the same time.
Imagine a wallet contains:
₦100,000Two withdrawal requests arrive simultaneously.
Request A wants:
₦80,000Request B wants:
₦50,000If both requests read the balance before either updates it, both may believe enough money exists.
This is a concurrency problem.
The database must help the application coordinate these operations safely.
Depending on the situation, solutions may involve:
These concepts become increasingly important in financial, inventory, booking, and other systems where two users can attempt to modify the same resource simultaneously.
Consider a payment request.
The client sends:
POST /paymentsThe server processes the payment.
Then the network times out.
The client doesn't know whether the payment succeeded.
So it retries.
Now the same payment request arrives twice.
If your system blindly creates a transaction every time, you could charge the customer twice.
This is why idempotency matters.
A client can provide an idempotency key:
payment_8f72a91The server records it.
If the same request arrives again, the application recognizes that the operation has already been processed.
This can be reinforced at the database level with an appropriate unique constraint.
The result is a much safer system.
This principle applies far beyond payments.
It is useful for:
At scale, retries are inevitable.
Your system must be designed for them.
Application validation is important.
But don't rely on application code alone to protect your data.
Suppose an email must be unique.
You could check:
Does this email already exist?Then insert the new user.
But two requests could arrive simultaneously.
Both might check.
Both might see that the email doesn't exist.
Both might attempt the insert.
A database-level unique constraint provides stronger protection:
UNIQUE(email)The same idea applies to:
Your application should validate.
Your database should enforce critical invariants.
Some developers avoid foreign keys because they believe they reduce performance or make databases harder to work with.
In many systems, foreign keys provide valuable protection against invalid relationships.
For example:
An order belongs to a user.
A payment belongs to an order.
An order item belongs to an order.
These relationships matter.
Without appropriate constraints, your database can accumulate orphaned records and inconsistent data.
Again, context matters.
Large-scale systems sometimes make deliberate trade-offs around database constraints, especially in distributed architectures.
But those decisions should be intentional.
Not simply because "constraints are annoying."
When a query becomes slow, developers often immediately rewrite the SQL.
Sometimes that's necessary.
But first ask:
What is the database actually doing?
Modern relational databases provide query analysis tools.
For example:
EXPLAIN
SELECT *
FROM transactions
WHERE user_id = 123
ORDER BY created_at DESC;The execution plan can reveal whether the database is:
This changes optimization from guesswork into engineering.
Instead of saying:
"This query feels slow."
You can investigate:
"The database is scanning 4.8 million rows because the filtering column isn't indexed."
That's a much better problem to solve.
Caching is powerful.
But it can also hide problems.
Imagine a dashboard takes five seconds to load because it performs an expensive query.
You add Redis.
Now the dashboard loads in 50 milliseconds.
Problem solved?
Maybe temporarily.
But eventually:
Caching is useful when applied to the right problem.
It should not become a substitute for fixing an inefficient database query.
A useful order of thinking is:
1. Is the query correct?
2. Is the schema appropriate?
3. Are the required indexes present?
4. Is the query retrieving unnecessary data?
5. Is the access pattern efficient?
6. Only then: should this result be cached?It's also important to distinguish between different layers of caching.
You might cache:
Each has different invalidation requirements.
For example, caching a list of blog posts is relatively simple.
Caching an account balance is much more sensitive.
The more important the data, the more carefully caching must be designed.
Fast incorrect data is still incorrect data.
Not every application stresses the database in the same way.
Some systems are read-heavy.
For example:
Others are write-heavy:
The architecture needs to account for the workload.
A database optimized for one workload may require different strategies for another.
This is why statements like:
"This is the best database architecture."
should immediately make you suspicious.
The better question is:
"Best for what workload?"
As read traffic increases, one database server may eventually become a bottleneck.
One strategy is replication.
A primary database handles writes.
One or more replicas handle reads.
Conceptually:
Application
|
-------------------
| |
Writes Reads
| |
Primary Read ReplicaThis can significantly increase read capacity.
But replication introduces an important concept:
The replica may not immediately contain the latest data written to the primary.
Imagine a user creates an order.
The application writes:
Order #1001to the primary database.
Immediately afterward, another request reads from a replica.
If replication hasn't caught up, the application may temporarily behave as though the order doesn't exist.
This is called eventual consistency.
Again, the architecture isn't "better" simply because replicas exist.
It introduces trade-offs.
Eventually, some systems become too large for a single database structure to handle efficiently.
At that point, more advanced techniques may become relevant.
Partitioning can divide a large table into smaller logical pieces.
For example, transactions could potentially be partitioned by:
Year
Month
Region
TenantInstead of one enormous logical dataset, the database works with smaller partitions.
Sharding goes further.
Data is distributed across multiple database instances.
For example:
Shard 1 → Users 1–1,000,000
Shard 2 → Users 1,000,001–2,000,000
Shard 3 → Users 2,000,001–3,000,000But sharding introduces significant complexity.
Queries become more difficult.
Cross-shard transactions become difficult.
Data distribution becomes a major concern.
Operational complexity increases.
This is why sharding should not be the first answer to a slow query.
A common response to database performance problems is:
"Let's upgrade the server."
More CPU.
More RAM.
Faster storage.
A larger cloud instance.
Sometimes that works.
But it may only delay the problem.
If the real issue is:
Missing indexthen more RAM doesn't fix the underlying query.
If the problem is:
N+1 queriesthen a bigger server simply processes inefficient requests with more resources.
If the problem is:
SELECT *across millions of rows, more CPU doesn't fundamentally change the access pattern.
Infrastructure can buy you time.
Engineering fixes the underlying problem.
You cannot reliably scale something you cannot observe.
Production databases should be monitored.
Important metrics can include:
Logging also matters.
If an endpoint suddenly becomes slow, you should be able to determine whether the problem is:
Application
↓
API
↓
Database query
↓
Lock
↓
DiskWithout observability, developers end up debugging production systems by intuition.
That is not scalable.
Your application doesn't usually create a completely new database connection for every request.
Instead, production systems commonly use connection pooling.
This allows connections to be reused.
But connection pools have limits.
Imagine an application suddenly receives thousands of concurrent requests.
If every request attempts to create its own database connection, the database can become overwhelmed.
You can end up with errors such as:
Too many connectionsThis is another reason scalability isn't simply about adding more servers.
Ten application servers can actually make a database problem worse if each server opens too many connections.
Scaling one layer can overload another.
The entire system must be considered.
One of the most overlooked aspects of database scalability is data retention.
Ask yourself:
Does the application need to keep every record forever?
Logs are a good example.
A system may generate:
API logs
Authentication logs
Audit logs
Error logs
Webhook logs
Analytics eventsIf nothing is ever archived or deleted, the database grows indefinitely.
Eventually, even simple operations become more expensive.
A mature system should have data lifecycle policies.
For example:
Hot data
↓
Frequently accessed
Warm data
↓
Occasionally accessed
Cold data
↓
Archived
Expired data
↓
DeletedNot every piece of data needs to remain in the primary production database forever.
Soft deletes are useful.
Instead of deleting a record:
DELETE FROM usersyou might mark it:
deleted_at = timestampThis allows recovery and historical tracking.
But there is a hidden cost.
The database still contains the record.
As the dataset grows, queries may repeatedly need to exclude deleted records.
Large systems therefore need to think carefully about retention, indexing, archiving, and whether every dataset actually needs soft deletion.
A feature that is convenient at 1,000 records can become expensive at 100 million.
This is where database engineering connects back to Part II.
We discussed designing software around business domains.
The same principle applies to data.
A payments system should understand payments.
An inventory system should understand inventory.
An identity system should understand identity.
The database schema should represent meaningful business entities and relationships rather than simply reflecting whatever objects happened to be easiest to code.
When the business changes, the data model may need to evolve.
That evolution should be intentional.
Changing a production database is not the same as changing a local database.
Imagine adding a new column:
ALTER TABLE transactions
ADD COLUMN reference VARCHAR(255);With a tiny database, the change may be almost invisible.
With hundreds of millions of records, schema changes can become operationally significant.
Depending on the database and exact operation, migrations may involve:
This is why experienced teams think carefully about database migrations.
Sometimes a safer approach is:
1. Add new column
2. Deploy code that supports both formats
3. Backfill existing records gradually
4. Switch application reads
5. Switch application writes
6. Remove old column laterThis is more complicated than changing a migration locally.
But production systems require production thinking.
One of the most dangerous assumptions is:
"We can just deploy the database change and then deploy the application."
What happens if old application servers are still running?
During a rolling deployment, you may temporarily have:
Old Application
+
New Application
+
Old Schema
+
New SchemaThe old code must still work while the new code is being introduced.
This is why backward-compatible database changes are so important.
Good deployments are designed around transition states.
Not just the final state.
Developers often think of APIs as HTTP endpoints.
But the database schema also becomes a kind of internal contract.
Once application logic depends heavily on a particular structure, changing that structure becomes expensive.
This is another reason abstraction matters.
Your business logic shouldn't be scattered everywhere with assumptions about raw database implementation details.
Good boundaries make future migrations easier.
Maybe one day you change:
MySQLto another relational database.
Or introduce:
Read replicasOr move analytics into:
A dedicated analytics platformThe cleaner your boundaries, the easier those transitions become.
The opposite mistake also exists.
Some systems attempt to use the database for everything.
Images.
Large files.
Temporary sessions.
Caches.
Search.
Analytics.
Logs.
Background jobs.
The database becomes the center of the universe.
That can work at small scale.
But specialized systems exist for specialized workloads.
You might eventually use:
Object Storage → Files
Redis → Caching
Queue → Background Jobs
Search Engine → Full-text Search
Analytics Warehouse → Large-scale Analytics
Relational Database → Transactional DataThe goal isn't to introduce all these technologies immediately.
The goal is to recognize when a workload has outgrown the tool currently handling it.
Developers sometimes assume scalable database architecture requires complicated technology.
Not necessarily.
A well-designed relational database with:
can support significant workloads.
You don't need five database technologies because your startup has 300 users.
You need to understand the workload.
Start with the simplest architecture that solves today's problems while preserving a reasonable path toward tomorrow's requirements.
Before declaring a database production-ready, ask:
These questions are far more valuable than simply asking:
"What database should I use?"
Here's a simple exercise I recommend.
Take your application's most important database tables.
Imagine:
10,000 usersThen:
100,000 usersThen:
1,000,000 usersNow ask:
You don't need to build for one million users today.
You need to understand what your current design will look like when the data becomes large.
That understanding allows you to make better decisions today.
There's another important lesson here.
Your system doesn't scale according to its strongest component.
It scales according to its bottleneck.
You might have:
Excellent frontend
+
Excellent API
+
Excellent infrastructure
+
Excellent caching
+
Weak database designThe database can still become the limiting factor.
Likewise:
Excellent database
+
Poor API architecturecan still produce a slow application.
Scalability is therefore a systems problem.
Every layer interacts with the others.
This may sound strange, but not every database operation needs to be optimized for maximum speed.
Some operations can tolerate seconds.
Others require milliseconds.
For example:
User login
→ Fast
Payment authorization
→ Fast and reliable
Analytics report
→ May tolerate slower processing
Monthly financial report
→ Could potentially run asynchronously
Historical export
→ May take minutesTrying to make every operation instantaneous can lead to unnecessary complexity.
Instead, classify workloads based on their requirements.
Some need:
Low latencyOthers need:
High throughputOthers need:
Strong consistencyOthers can tolerate:
Eventual consistencyEngineering is about matching the system to the requirement.
Applications come and go.
Features change.
Frameworks change.
Cloud providers change.
But one thing remains:
Data accumulates.
Every successful application creates more of it.
More users create more records.
More transactions create more history.
More features create more relationships.
More years create more complexity.
This means database scalability isn't something you solve once.
It is something you continuously manage.
The database that works perfectly during your first month of development may behave very differently after five years of production data.
The developers who understand this don't wait for the database to become slow before thinking about scale.
They design with growth in mind.
They monitor real workloads.
They inspect queries.
They understand indexes.
They protect consistency.
They plan migrations.
They control data growth.
And most importantly, they recognize that performance isn't just about how quickly a query runs today.
It is about how that query behaves when the database contains ten times, one hundred times, or one thousand times more data.
Because eventually, every successful application reaches the same question:
"What happens when there is a lot more data than we have today?"
The answer shouldn't be:
"We'll figure it out when we get there."
It should already be part of the engineering conversation.
We've now covered three critical layers of scalable software.
Part I — The Foundation
We explored the engineering mindset, technical debt, the different dimensions of scalability, and why software should be designed for growth rather than rewritten because of it.
Part II — The Architecture
We examined coupling, cohesion, monoliths, modular monoliths, microservices, APIs, events, business domains, and the trade-offs behind architectural decisions.
Part III — The Database
We went beneath the application layer and examined schema design, indexes, queries, transactions, concurrency, pagination, caching, replication, data growth, and database observability.
But there is still another major problem.
Even if your application is architecturally sound and your database is properly designed, you can still bring the entire system down by making one mistake:
Doing too much work synchronously.
Sending emails.
Processing images.
Generating reports.
Calling third-party APIs.
Sending notifications.
Processing webhooks.
Generating invoices.
Running analytics.
None of these tasks necessarily need to happen while the user waits for an HTTP response.
And when thousands of users start performing these operations simultaneously, synchronous processing can become one of the biggest bottlenecks in the entire system.
That brings us to the next layer of scalability:
Queues, background jobs, asynchronous processing, and event-driven systems.
Because sometimes the best way to make software faster isn't to make the work faster.
It's to stop making the user wait for it.
Your email address will not be published. Required fields are marked *