Статья опубликована на английском языке.
Why Backend Code That Works Locally Breaks Under Real Production Load
A practical rundown of the environment, database, security, and reliability assumptions that quietly work on localhost but cause outages once real traffic hits production.
It runs perfectly on your machine. But will it hold up once real traffic hits it? Here are 10 backend mistakes that can quietly turn a smooth-running application into a production disaster.
Locally, everything seems fine. Requests come back fast. Queries return the right data. Login works. Docker spins up without complaint. Tests pass. Then you deploy, open the live URL, and something breaks.
Sometimes the app refuses to boot.
Sometimes users get locked out of their accounts.
Sometimes the database grinds to a crawl out of nowhere.
Sometimes things run smoothly for a few minutes before connection errors start piling up.
And sometimes the service looks perfectly fine on the surface while causing quiet damage underneath.
What makes this frustrating is that most of these failures have nothing to do with tricky algorithms.
They come from small, unexamined assumptions.
Assumptions such as:
- "We won't have that much traffic."
- "The database lives on this same box."
- "This environment variable will always be set."
- "A single database connection is fine."
- "Requests will always finish fast."
- "Files saved to disk will still be there later."
- "Nobody will hammer this endpoint."
- "The API will only ever get valid input."
- "The container will never run low on memory."
- "A running process means a healthy application."
Your local setup shields you from most of these issues.
Production offers no such protection.
This piece walks through 10 backend mistakes that tend to work flawlessly on localhost but cause real damage once deployed.
None of these are abstract or hypothetical.
They're the kind of shortcuts that feel perfectly reasonable while you're heads-down building a feature.
That's precisely what makes them risky.
1. Treating Environment Variables Like Optional Configuration
Let's begin with one of the most frequent slip-ups.
Say you're building an API on your laptop.
Your database connection setup looks like this:
DATABASE_URL=postgresql://postgres:password@localhost:5432/myapp
JWT_SECRET=my-super-secret-key
PORT=5000
The app boots up fine.
Nothing seems wrong.
You ship it to production.
And then, out of nowhere:
Error: password authentication failed
Or maybe:
JWT_SECRET is undefined
Or:
ECONNREFUSED 127.0.0.1:5432
Your first instinct might be to blame the hosting platform.
That's rarely the actual cause.
What's really happening is that your app was quietly relying on settings that only existed on your dev machine.
The localhost trap
Locally, you might have something like:
DATABASE_URL=postgresql://localhost/myapp
While production is configured as:
DATABASE_URL=postgresql://db.internal:5432/myapp
These are two entirely different setups.
It gets worse when developers bake configuration directly into the source code:
const dbHost = "localhost";
const dbUser = "postgres";
const dbPassword = "password";
This works great.
Right up until it doesn't.
Production is not your laptop.
The database could be sitting on:
- a separate server
- a different container
- a managed database provider
- a private internal network
- a different availability zone
- an entirely different cloud vendor
Your app needs a way to discover where that database actually is.
Configuration should come from the environment
A more resilient pattern looks like this:
const config = {
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
port: process.env.PORT || 5000
};
But there's a subtler mistake still lurking here.
Just pulling values from environment variables isn't sufficient on its own.
You also need to verify they're actually present.
For instance:
const requiredEnv = [
"DATABASE_URL", "JWT_SECRET"
];
for (const key of requiredEnv) {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`);
}
}
Now the app refuses to start if a required setting is missing.
That kind of early failure is actually a good thing.
Compare it to a scenario where the app boots normally and only breaks the moment the first user tries to sign in.
Seeing something like:
Missing required environment variable: JWT_SECRET
during deployment is far better than learning about it from an angry support ticket.
Don't silently provide dangerous defaults
This shortcut is tempting:
const jwtSecret = process.env.JWT_SECRET || "secret";
It smooths over local development.
It also opens the door to a security hole.
Suppose you forget to set JWT_SECRET in your production environment.
The app won't crash.
It will just quietly fall back to:
secret
Now your live system is signing tokens with a value anyone could guess.
A safer version looks like this:
if (!process.env.JWT_SECRET) {
throw new Error("JWT_SECRET must be configured");
}
Fallback values make sense for harmless settings.
They're a liability for anything security-related.
Configuration validation belongs at startup
A pattern worth adopting is treating your configuration almost like a required dependency, one that must be satisfied before anything else runs.
For example:
function loadConfig() {
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL is required");
}
if (!process.env.JWT_SECRET) {
throw new Error("JWT_SECRET is required");
}
return {
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
port: Number(process.env.PORT || 5000)
};
}
const config = loadConfig();
This gives your app a firm contract to work with.
Either the configuration is valid, or the process never starts.
That tradeoff is well worth it.
2. Pointing at localhost for Services That Live Outside Your App
This mistake deserves its own spotlight because it shows up constantly once code leaves a developer's machine.
Say your backend talks to PostgreSQL.
On your laptop, the setup looks like this:
Backend → localhost:5432 → PostgreSQL
And it just works.
Then you containerize the backend with Docker.
PostgreSQL now runs in a separate container.
You might leave the config untouched:
DATABASE_HOST=localhost
And then you get:
connection refused
What's going on?
localhost no longer refers to what you think it does.
From inside a container:
localhost
refers to:
this specific container.
Not:
the container running your database.
Misunderstanding this single distinction accounts for a huge share of Docker deployment headaches.
Docker Networking Requires a Different Mental Model
Picture three separate containers:
backend
database
redis
You might define them like this:
services:
backend:
build: .
depends_on:
- database
- redis
database:
image: postgres
redis:
image: redis
From inside the backend container, you can't just reach:
localhost:5432
Instead, you address the other container by its service name:
database:5432
Same logic applies to Redis:
redis:6379
The resulting network topology looks like:
backend
|
+---- database:5432
|
+---- redis:6379
This feels unfamiliar at first, since local setups usually run everything directly on your host machine. Production adds real network boundaries between components.
Cloud Services Introduce the Same Trap
Locally, your app might reach Redis at:
localhost:6379
But in production, Redis lives on a managed service somewhere else entirely.
The right configuration there might be:
REDIS_URL=redis://redis-production.example.com:6379
Your application shouldn't need to know the difference.
That's the key idea.
Write your code to consume configuration like this:
createRedisClient({
url: process.env.REDIS_URL
});
Rather than hardcoding connection details like this:
createRedisClient({
host: "localhost",
port: 6379
});
Keep the Shape of Configuration Consistent Across Environments
The values will change between environments.
The structure should not.
For instance, in development:
DATABASE_URL=postgresql://localhost:5432/myapp
REDIS_URL=redis://localhost:6379
And in production:
DATABASE_URL=postgresql://production-db:5432/myapp
REDIS_URL=redis://production-redis:6379
Same codebase.
Different environment values.
That consistency is exactly what you're aiming for.
3. Opening a Fresh Database Connection on Every Request
This particular mistake can hide in plain sight for a long time before it causes trouble.
You write a route like this:
app.get("/users", async (req, res) => {
const client = new Client({
connectionString: process.env.DATABASE_URL
});
await client.connect();
const result = await client.query("SELECT * FROM users");
await client.end();
res.json(result.rows);
});
It runs fine.
You test it manually.
Responses come back quickly.
You ship it.
Then real traffic arrives.
Eventually you start seeing errors like:
too many connections
or:
remaining connection slots are reserved
or you notice your database's CPU usage climbing steadily.
What's the cause?
Each incoming request opens a brand-new database connection.
With 100 simultaneous requests, that's potentially 100 separate connections.
With 1,000 requests, the situation gets far worse.
Connections Are Expensive to Create
Establishing a connection isn't free. Depending on your database and setup, it can involve:
- setting up the network connection
- authenticating
- negotiating TLS
- initializing the session
- allocating resources
- spinning up a process or thread on the database side
Repeating all of that for every single request wastes resources. That's why applications typically rely on a connection pool instead.
With PostgreSQL, that looks like:
const { Pool } = require("pg");
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10
});
And then your route becomes:
app.get("/users", async (req, res) => {
const result = await pool.query(
"SELECT id, name, email FROM users"
);
res.json(result.rows);
});
The pool keeps a set of reusable connections ready to go.
Visually, it looks like this:
┌── connection 1
Request ────────┼── connection 2
├── connection 3
├── connection 4
└── connection 5
Rather than this:
Request 1 → new connection
Request 2 → new connection
Request 3 → new connection
Request 4 → new connection
Pools Can Still Cause Trouble at Scale
Even with pooling in place, misconfiguration is still possible.
Say you run 10 backend instances, and each one sets:
pool.max = 20
That adds up to a potential:
10 × 20 = 200
connections. If your database caps connections at 100, you're already over budget, even though each individual instance's pool setting looks reasonable on its own.
This is exactly the kind of issue that never surfaces on a single developer machine.
Locally, you might see:
1 application × 10 connections = 10
In production:
10 applications × 20 connections = 200
Scaling out your application horizontally can quietly multiply the load it places on the database.
Pool sizing is a system-wide concern
When figuring out how large a pool should be, you need to factor in:
database connection limit
↓
number of application instances
↓
pool size per instance
↓
background workers
↓
admin/monitoring connections
This isn't something you pick arbitrarily.
4. Returning Every Database Column to the Client
This particular mistake rarely brings your app crashing down right away.
That's precisely why it's risky.
Picture a users table with these fields:
id
name
email
password_hash
phone
address
created_at
updated_at
internal_notes
You write a query like:
SELECT * FROM users;
And then hand the rows straight to the response:
res.json(result.rows);
It works. The frontend receives its user object. You move on.
But underneath, you've introduced multiple issues at once.
First: data exposure
The response might look like:
{
"id": 12,
"name": "John",
"email": "john@example.com",
"password_hash": "...",
"internal_notes": "..."
}
Even if the password field is hashed rather than plaintext, there's no reason it should ever reach a client. And any internal-only fields you forgot about get shipped out right along with it.
Second: bandwidth
Say each record weighs in at 2 KB. For a thousand records, that's:
2 KB × 1,000 = 2 MB
Now picture the table growing wider over time. You might end up serving:
20 fields
when the UI genuinely only consumes:
id
name
avatar
The database has no idea what your frontend actually requires — that decision belongs to your API layer.
So instead of:
SELECT * FROM users;
you should write:
SELECT
id,
name,
avatar_url
FROM users;
Now the payload contains only what that particular endpoint needs.
This becomes even more important with joins
Consider a query like:
SELECT *
FROM users
JOIN orders ON orders.user_id = users.id
JOIN payments ON payments.order_id = orders.id;
This can pull back a massive amount of data you never intended to send anywhere. It's a tempting shortcut while you're still building things out, with the reasoning going something like:
"I'll just return everything and let the frontend pick what it needs."
Resist that urge.
An API response shape should be intentional. For example:
const users = result.rows.map(user => ({
id: user.id,
name: user.name,
avatar: user.avatar_url
}));
Or, even better, push this responsibility into a dedicated DTO or serialization layer.
The API contract matters
A production-grade API should be able to answer:
What does this endpoint promise to return?
Not:
What columns happen to exist in the underlying table?
That distinction only gets more important as the codebase grows.
5. Ignoring Database Indexes Until Production Becomes Slow
This might be one of the clearest examples of something that seems totally fine locally but quietly turns into a production problem.
Take a query like:
SELECT * FROM users
WHERE email = 'john@example.com';
While your dev database has:
500 users
the query returns instantly.
You ship it. Six months go by, and the table now holds:
5 million users
Suddenly that same query can get dramatically slower, since the database may need to scan a huge share of the table to find a match.
The typical reaction is:
"But this query used to be fast!"
Sure — because back then, the table was tiny.
Indexes exist for this reason
Adding something like:
CREATE INDEX idx_users_email
ON users(email);
lets PostgreSQL locate matching rows far more efficiently than a full table scan.
That said, the takeaway here isn't:
Just index everything.
That's a mistake in its own right. Indexes aren't free — they:
- take up disk space
- slow down writes
- require ongoing maintenance
- don't help with every kind of query
- aren't worth adding on every column
The question to ask instead is:
Which queries does this application actually run in practice?
Index based on access patterns
If your app regularly runs something like:
SELECT *
FROM orders
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20;
a composite index along these lines might make sense:
CREATE INDEX idx_orders_user_created
ON orders(user_id, created_at DESC);
The right indexing choice always depends on your specific database engine, how the data is distributed, how the query planner behaves, and your actual workload — which is exactly why indexing by guesswork doesn't work.
Use EXPLAIN
One of the most valuable diagnostic tools in PostgreSQL is:
EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20;
It shows you exactly how the database executed a given query, replacing assumptions like:
This query should run fast
with actual evidence:
Here's what the database is really doing
That shift in mindset matters a great deal.
Local data can lie
Here's a rule worth internalizing: performance measured against a small local dataset tells you very little about how the same query behaves in production.
A query that completes in:
5 ms
against 1,000 rows can behave completely differently once you're dealing with:
10 million rows
Testing performance meaningfully requires data volumes that resemble what you'll actually see in production.
6. Believing Whatever You Save to Disk Will Stay There
This problem tends to surface around file uploads.
On your own machine, you might write something like:
app.post("/upload", upload.single("file"), (req, res) => {
console.log(req.file.path);
});
The uploaded file lands somewhere like:
/uploads/photo.jpg
You reload the app.
The file is right where you left it.
Nothing seems wrong.
Then you ship the app to a container or some ephemeral hosting platform.
A new deploy rolls out.
Suddenly the file is gone.
Support tickets start coming in:
Where did my images go?
Why does this happen?
Because the filesystem on your local machine behaves very differently from storage inside many production environments.
A container typically goes through a lifecycle like:
created
running
destroyed
recreated
Once that container is torn down, anything written only to its local disk vanishes along with it.
Plenty of hosting platforms treat locally-written files as disposable by design.
Stop treating your app server as long-term storage
For handling uploads in production, the usual approach is to push files into dedicated object storage instead of the server's own disk.
That gives you an architecture roughly like this:
Client
↓
Backend
↓
Object Storage
↓
Database stores file metadata
Your database, meanwhile, only needs to hold a reference — something like:
id
user_id
filename
storage_key
content_type
created_at
The actual bytes of the file sit in the object storage service, not in your relational database.
Splitting responsibilities this way keeps things much simpler to reason about.
The database and your storage layer solve different problems
Databases excel at handling:
- structured records
- relationships between records
- transactional guarantees
- querying and filtering
Object storage is built for:
- images
- video
- PDFs and other large documents
- static assets in general
Trying to make one system do both jobs usually backfires eventually.
A related trap: committing uploaded files into your codebase
It's not unusual to find projects laid out like:
project/
uploads/
src/
package.json
where user uploads live right alongside the application's own source files.
That approach falls apart quickly.
Picture:
100,000 uploaded images
sitting inside your project folder.
Deploys become painfully slow and messy.
Application code and user-generated content follow completely different lifecycles, so they belong in separate systems.
7. Shipping an API With No Rate Limiting
Working locally sets up a misleading scenario.
Chances are you're the only client hitting the API.
You fire off:
GET /api/users
once.
Then again.
Maybe a handful more times.
Nothing breaks.
Production doesn't behave that politely.
A single caller — by accident or on purpose — can fire off thousands of requests in a short window.
Take an endpoint like:
POST /api/login
Without any throttling in place, an attacker could hammer it with repeated login attempts:
attempt 1
attempt 2
attempt 3
...
attempt 10,000
Even a perfectly secure password check doesn't stop the endpoint itself from being abused at scale.
Rate limiting introduces a checkpoint
Conceptually, it slots in like this:
Client
↓
Rate limiter
↓
Application
Rather than letting requests flow through without limit, you might cap a given class of endpoint at something like:
100 requests / minute
Once a caller crosses that threshold, they get back:
429 Too Many Requests
The right numbers vary by use case — a public search endpoint doesn't need the same ceiling as something like:
POST /login
or:
POST /forgot-password
It's not only about malicious traffic
Rate limits also guard against bugs on the client side.
Imagine a stray piece of frontend code like:
setInterval(() => {
fetch("/api/notifications");
}, 100);
That single browser tab is now hammering your API nonstop.
Now scale that mistake across 10,000 users.
Your backend suddenly has a real problem on its hands.
A rate limiter gives you a safety net against exactly this kind of runaway behavior.
Rate limiting gets trickier once you scale horizontally
Say your app runs behind three separate backend instances:
Server A
Server B
Server C
If each instance keeps its own counter in memory, a client could effectively get three times the intended allowance, one per server.
To enforce a consistent limit across all instances, teams typically reach for a shared store like Redis.
The setup then looks something like:
┌── Server A
Client → LB ──┼── Server B
└── Server C
|
Redis
With that shared state, every instance enforces the same limit consistently.
Once again, this is invisible when you're running a single process locally — there's no distributed state to worry about until production forces the issue.
8. Assuming Requests Always Complete in a Reasonable Time
This one sneaks up on you.
Say you build an endpoint like:
app.get("/report", async (req, res) => {
const data = await generateHugeReport();
res.json(data);
});
Locally, the report covers:
1,000 records
and finishes in:
300 ms
Looks fine.
In production, that same report now spans:
5 million records
and the request now takes:
45 seconds
Meanwhile, your reverse proxy might be configured to give up after:
30 seconds
so the client ends up seeing:
504 Gateway Timeout
even though your backend is still quietly grinding away in the background.
That mismatch creates a confusing failure mode.
Long-running work doesn't belong inside a request-response cycle
When a task is going to take a while, it's usually better handled as a background job rather than something the client waits on directly.
Instead of a flow like:
Client
↓
POST /generate-report
↓
Wait 45 seconds
↓
Response
you move to something like:
Client
↓
POST /generate-report
↓
Job created
↓
202 Accepted
with the actual processing happening separately:
Queue
↓
Worker
↓
Generate report
↓
Store result
The client can then poll for status through something like:
GET /reports/:id
or simply get notified once the job finishes.
Background queues handle the heavy lifting
Common examples of work that should run outside the main request cycle include:
- sending emails
- generating PDFs
- processing images
- processing video
- building reports
- importing data
- dispatching notifications
- producing large exports
- running scheduled jobs
Keeping these off the main thread means your core API stays fast and responsive.
Don't forget about timeouts
Any call your service makes to an external system should have a sensible timeout attached.
As an example:
fetch(url, {
signal: AbortSignal.timeout(5000)
});
If you skip this, a dependency that freezes up can leave your application waiting forever.
Picture this chain:
API
↓
Payment service
↓
hangs
Your request is now stuck, waiting on something that may never respond.
Now imagine that happening across hundreds of simultaneous requests.
At some point, your server runs out of available resources to handle anything else.
Every dependency needs a failure plan
For each external service your application talks to, you should be able to answer one specific question:
What happens if this service doesn't respond?
Not this one:
What happens when everything works?
That second question is trivial to answer.
The first is the one that actually determines whether your system stays up under pressure.
9. Trusting Client Input
This mistake is almost uncomfortable to point out because the offending code often looks perfectly reasonable.
Take an endpoint like:
app.post("/users", async (req, res) => {
const { name, email } = req.body;
await createUser(name, email);
res.json({
message: "User created"
});
});
At first glance, nothing seems wrong.
But consider what happens if the request body looks like this:
{
"name": "",
"email": "hello"
}
Or like this:
{
"name": null,
"email": null
}
Or like this:
{
"name": "A".repeat(1000000),
"email": "..."
}
What happens if the client includes fields your code never anticipated?
While developing locally, you tend to send carefully constructed test requests.
In production, your API receives whatever anyone decides to send it.
Validation needs to happen at the edge
A well-built API checks incoming data before any business logic runs.
For instance, you might define a shape like:
const schema = {
name: "string",
email: "email"
};
With a validation library, you can enforce rules such as:
name required
name length <= 100
email required
email valid
It doesn't matter much which specific validation library you pick.
What matters is the underlying rule:
Never assume the client sent what you expected.
Keep in mind that "the client" could be:
- your own frontend
- a mobile app
- a third-party integration
- an automated script
- an outdated build of your app
- someone with malicious intent
Client-side checks are not a security layer
A common objection is:
"But I already validate this on the frontend, in React."
That's genuinely helpful for the user experience.
It does nothing to protect your backend.
Anyone can skip your frontend code entirely.
All it takes is running:
curl
pointed straight at your API endpoint.
Your server-side code still has to enforce the rules itself.
SQL injection is a related risk
Avoid building queries by concatenating strings, like this:
const query = `
SELECT *
FROM users
WHERE email = '${email}'
`;
Instead, rely on parameterized queries:
const result = await pool.query(
"SELECT * FROM users WHERE email = $1",
[email]
);
This way, the database driver keeps the parameter values separate from the query's structure.
Input validation and parameterized queries address different threats, but a production-grade backend needs both.
10. Thinking "Application Running" Means "Application Healthy"
This might be the most fundamental misunderstanding on this list.
Your server boots up:
Server listening on port 5000
The logs confirm:
Application started successfully.
At that point, it's tempting to think:
Great. We're done.
That's not necessarily true.
A process can keep running even while the application itself is effectively broken.
Consider this state:
Backend process: running
Database: unavailable
Redis: unavailable
Payment provider: unavailable
Queue: stuck
From the operating system's perspective, the process is alive and well.
Your monitoring dashboard might show:
CPU: 10%
Memory: 20%
Process: alive
Meanwhile, actual users are seeing:
500 Internal Server Error
Health checks fill part of the gap
A basic health endpoint like:
GET /health
might simply return:
{
"status": "ok"
}
But even this can be deceiving.
If /health only confirms that the HTTP server responds to requests, it says nothing about whether the dependencies your app actually relies on are functioning.
A more complete setup separates two endpoints. One:
/health
for a basic liveness check, and another:
/ready
for readiness.
The distinction, conceptually, looks like this:
Liveness: "Is this process alive?"
and:
Readiness:
"Can this instance actually serve traffic?"
Checking database health specifically
A readiness check can go further and confirm the database connection is actually working, for example by running:
SELECT 1;
If the database can't be reached, the application can mark itself as not ready.
This gives orchestration tools a way to stop routing traffic to an instance that can't actually serve requests properly.
Uptime alone tells you very little
A server can report:
99.99% uptime
while still delivering a frustratingly slow experience to its users.
That's why production monitoring needs to track more than whether the process is alive, including things like:
- request latency
- error rate
- throughput
- database performance
- CPU usage
- memory usage
- queue depth
- failures in external services
One particularly useful metric is:
p95 latency
Rather than asking:
What's the average response time?
it's more revealing to ask:
How long do 95% of requests take?
Averages tend to hide the worst experiences.
Consider this scenario:
99 requests = 50 ms
1 request = 30 seconds
The average response time here might not look alarming at all.
But that single slow request still represented a genuinely bad experience for whoever made it.
The Extra Mistake: Logging Everything but the Useful Parts
This one earns its place on the list because it has rescued more than one debugging session.
Something breaks in production.
You open the logs.
All you find is:
Something went wrong
That's the entire message.
No request identifier.
No route information.
No user context.
No stack trace.
No indication of which database call was involved.
No timestamp precise enough to correlate with anything.
Just a bare line saying:
Something went wrong
At that point you're troubleshooting with no real information to go on.
What a Useful Log Entry Looks Like
When something fails, the questions you want answered are:
When?
Where?
Which request?
Which service?
What failed?
How long did it take?
What dependency was involved?
A properly structured log entry captures that context, something like:
{
"level": "error",
"requestId": "abc-123",
"route": "/api/orders",
"method": "POST",
"duration": 843,
"error": "Database timeout"
}
That version actually gives you something to work with.
Never Log Sensitive Data
At the same time, be careful never to casually record:
password
access token
refresh token
credit card information
private keys
Logging exists to help you diagnose problems, not to hand attackers a second one.
Why These Issues Never Show Up on Your Laptop
You might reasonably ask why so many developers repeat these same mistakes if they're this common.
The answer is that a local setup and a production environment are fundamentally different systems.
Here's the contrast.
What Local Development Looks Like
Typically you're dealing with:
1 developer
1 application
1 database
small dataset
low traffic
fast network
known inputs
stable filesystem
single process
Production, meanwhile, might involve:
thousands of users
multiple application instances
millions of database rows
unpredictable traffic
network latency
invalid requests
external dependencies
ephemeral containers
background workers
multiple regions
That's not a smaller version of the same system — it's a different system entirely.
The source code might be unchanged.
The environment around it is not.
Falling Into the Happy-Path Habit
It's natural to test only the successful flow.
A typical example:
Create user
↓
Login
↓
Create order
↓
Payment succeeds
↓
Return response
That sequence represents the best-case scenario.
But production traffic is never limited to best-case scenarios.
You also have to account for situations like:
What if the database is slow?
What if Redis is unavailable?
What if the payment service times out?
What if the user sends invalid JSON?
What if the request is duplicated?
What if two requests update the same record?
What if the process restarts?
What if the container disappears?
What if the database connection pool is exhausted?
What if the user clicks the button five times?
What if the request arrives twice?
What if the response is lost after the database transaction succeeds?
In a distributed system, these aren't rare corner cases.
They're routine occurrences.
Shifting From "Does It Work" to "What If It Fails"
As experience with backend systems grows, your instincts change.
Early on, the question is:
Does this code work?
Eventually it becomes:
What happens if this code fails?
That second question is far more useful.
Take this line:
const result = await paymentService.charge();
A beginner's reasoning stops at:
If the call succeeds, move on.
Production-minded reasoning keeps going:
What if it times out?
Then:
What if the charge goes through but the response back to my API times out?
Then:
What if the client retries the request?
Then:
Could the customer end up billed twice?
At that point you're reasoning about failure modes, which is really the core of backend engineering.
Idempotency: What Keeps Payment Flows Safe
Extending that same example: imagine a user taps
Pay
The request hits your server.
The charge succeeds.
But the connection drops before the response makes it back.
The client has no way of knowing the payment actually went through, so it retries.
Now the server has processed:
Payment request #1 → success
Payment request #2 → success
Which could easily result in:
Customer charged twice.
Idempotency exists precisely to prevent this.
An idempotency key might look like this:
payment_7f8c123
The client attaches this key to the request, and the server stores it alongside the outcome of that operation. If a request arrives again carrying the same key, the server simply returns the stored result instead of executing the operation a second time.
In outline form:
Request
↓
Idempotency key
↓
Already processed?
├── Yes → return previous result
└── No → process → store result
This pattern matters a great deal for operations such as:
- payments
- order creation
- account signup
- sending emails
- calls to third-party APIs
Duplicate-request bugs rarely surface during local testing, but they show up constantly once real, unreliable networks are involved.
Transactions: Another Thing That Looks Fine Until It Doesn't
Think about what happens when an order gets created.
The workflow might involve several steps:
Create order
Create order items
Update inventory
Create payment record
If you don't wrap these operations in a transaction, one of them can fail partway through the sequence.
Picture this scenario:
Create order ✓
Create items ✓
Update inventory ✓
Payment record ✗
At this point your database is sitting in a half-finished state.
Transactions exist so you can bundle operations that must all succeed or all fail as a unit.
Here's what that looks like in practice:
BEGIN;
INSERT INTO orders (...);
INSERT INTO order_items (...);
UPDATE inventory
SET quantity = quantity - 1
WHERE product_id = 123;
COMMIT;
And when something breaks along the way:
ROLLBACK;
That said, wrapping every single operation into one massive transaction isn't the right approach either.
Transaction boundaries need thoughtful design.
Transactions that run too long can introduce their own set of headaches.
The question you always want to be asking is:
If this fails partway through, what condition does that leave the system in?
N+1 Queries: The Silent Performance Killer
There's another issue that tends to stay hidden while you're developing locally.
Say you pull a list of users:
SELECT * FROM users LIMIT 100;
And then, for each individual user, you go fetch their orders:
SELECT * FROM orders WHERE user_id = ?;
That adds up to:
1 query for users
+
100 queries for orders
=
101 queries
This is the well-known N+1 query trap.
If you only have 10 users:
11 queries
That's manageable.
But scale up to 10,000 users:
10,001 queries
and suddenly you've got a real performance problem.
The fix isn't necessarily "combine everything into one massive query."
Depending on your situation, you might reach for:
- joins
- eager loading
- batching
IN (...)clauses- data loaders
- queries designed specifically to avoid this pattern
What matters most is being able to spot the pattern in the first place.
A backend can seem perfectly snappy against a tiny local dataset while secretly firing off thousands of queries under real load.
Pagination Is Not Optional for Large Collections
An endpoint like this seems harmless enough:
GET /users
And the query behind it:
SELECT *
FROM users;
runs just fine when you have:
50 users
But what happens once you're dealing with:
5 million users?
Sending five million records back to a client isn't something you want to do.
Pagination gives you a way to cap that.
You could do offset-based pagination:
GET /users?page=2&limit=50
or cursor-based pagination:
GET /users?cursor=abc123&limit=50
Cursor pagination tends to work better for large or constantly-changing datasets, since it sidesteps some of the performance and consistency headaches that come with very large offsets.
Once more, the core issue is:
localhost data = small
production data = potentially huge
Your API needs to be built with that gap in mind.
Memory Leaks Don't Always Show Up Immediately
This is another type of bug that reveals itself only in production.
Your app boots up using:
Memory: 150 MB
An hour later:
300 MB
Six hours later:
600 MB
And eventually:
Out of memory
Then it crashes and restarts.
At which point everything looks normal again.
That pattern is a strong signal that something is holding onto memory it shouldn't be.
Common culprits include:
- arrays growing without any cap
- caches with no eviction limit
- event listeners that never get cleaned up
- large objects lingering in memory unintentionally
- references that live far longer than they should
- streams that aren't handled correctly
When you test locally, the app rarely stays running long enough for this kind of issue to surface.
In production, it runs long enough for the leak to matter.
That's part of why watching an application's behavior over extended periods is so valuable.
Caching Can Also Break Production
On paper, caching looks like a straightforward win:
Database → slow
Redis → fast
So the instinct is to cache everything.
But then the underlying data changes.
And the API keeps handing back outdated information.
Now you end up with a mismatch like:
Database:
balance = 100
Cache:
balance = 80
The API responds with:
80
And the user wants to know:
Why is my balance wrong?
Caching forces you to answer questions such as:
- When should entries expire?
- When does the cache get invalidated?
- What's the fallback if Redis goes down?
- What happens when two servers write to the same key?
- Is serving stale data ever acceptable here?
- What should happen right after a deployment?
The hard part of caching was never storing the data.
It's figuring out the moment that stored data stops being trustworthy.
Cache failures should have a strategy
If Redis vanishes unexpectedly, does your app:
fail completely
or does it:
fall back to database
There's no universal answer — it depends on the system you're building.
What matters is that you make that call deliberately, rather than by accident.
Secrets in Git: The Mistake That Can Become a Security Incident
This next one feels like common sense, yet it keeps happening.
You set up a file:
DATABASE_PASSWORD=...
JWT_SECRET=...
API_KEY=...
And then run:
git add .
git commit -m "config"
git push
At that point, the secret is now baked into your Git history.
Deleting the file afterward doesn't erase it — the value can still be recovered from earlier commits.
A better setup looks like keeping:
.env
listed inside .gitignore, and committing a template instead:
.env.example
with placeholder entries such as:
DATABASE_URL=
JWT_SECRET=
REDIS_URL=
The real values should be injected through your deployment platform's environment settings or a dedicated secrets manager.
And if a genuine secret does slip into a commit:
Don't just remove the line and call it done.
Rotate that secret immediately.
Treat it as compromised, whether or not you can prove it was ever accessed.
CORS Is Not Authentication
Here's another point that trips people up:
Access-Control-Allow-Origin: *
CORS governs how browsers handle cross-origin requests.
It does nothing to actually lock down your API.
Nothing stops a server endpoint from being hit directly through:
curl
Postman
mobile apps
backend services
scripts
CORS was never designed to act as an authentication layer.
Your API still needs real:
- authentication
- authorization
- input validation
- rate limiting
- access control
Security has to be enforced explicitly, not implied by browser policy.
Authentication and Authorization Are Different
Confusing these two leads to genuine security holes.
Authentication answers:
Who are you?
Authorization answers:
Are you allowed to do this?
Consider a request like:
GET /users/123
The user might well be authenticated.
But that says nothing about whether they should be allowed to view:
user 123
If the person making the request is:
user 456
the server still has to figure out whether:
456 → allowed to access 123?
A frequent mistake looks like this:
if (req.user) {
return getUser(req.params.id);
}
That code only verifies that someone is logged in.
It never checks whether they're permitted to access that specific resource.
A safer pattern factors in ownership or explicit permissions:
if (req.user.id !== req.params.id) {
return res.status(403).json({
error: "Forbidden"
});
}
The specific rules will vary by application.
But the underlying principle stays the same everywhere:
Logging in doesn't grant blanket access to everything in the system.
Deployment Is Part of the Application
A lesson worth internalizing early is that deployment isn't a separate phase tacked onto the end of development.
It's part of the engineering work itself.
Your application isn't only:
source code
It's a combination:
source code
+
configuration
+
database
+
network
+
storage
+
secrets
+
dependencies
+
monitoring
+
deployment process
Someone who understands only the application code can still find themselves overwhelmed once things move to production.
You don't need to master every detail of Kubernetes to be effective.
But you do need a working understanding of the environment your code actually runs in.
A Production Readiness Checklist I Actually Use
Before declaring something ready for production, it helps to walk through a checklist like this one.
Configuration
- Are required environment variables validated at startup?
- Are secrets kept out of the source code?
- Are the production defaults safe rather than convenient?
- Are development and production configurations kept separate?
Database
- Is connection pooling set up?
- Is the pool sized appropriately?
- Are the important queries indexed?
- Have slow queries actually been profiled?
- Are transactions used wherever they're needed?
- Are migrations tracked with version control?
API
- Is incoming input validated?
- Are response payloads shaped deliberately?
- Are large result sets paginated?
- Are request body sizes capped?
- Are reasonable timeouts configured?
Security
- Are passwords hashed with a secure algorithm?
- Are secrets stored securely?
- Is authorization actually enforced?
- Are SQL queries parameterized?
- Is rate limiting applied where it matters?
- Are sensitive fields kept out of logs?
Infrastructure
- Does anything wrongly depend on
localhost? - Is persistent data kept outside of ephemeral containers?
- Are health check endpoints exposed?
- Is graceful shutdown implemented?
- What actually happens when the application restarts?
Performance
- Has the system been tested against realistic data volumes?
- Are there hidden N+1 query patterns?
- Are expensive tasks offloaded to background jobs?
- Are caches bounded in size?
- Is memory usage being tracked?
Observability
- Do error logs include enough context to be useful?
- Is request tracing set up?
- Are latency and error rates monitored?
- Are database failures visible in monitoring?
- Are alerts actually configured?
Graceful Shutdown: The Small Detail People Forget
Picture your server receiving a:
SIGTERM
signal because the hosting platform wants to restart the instance.
If the process terminates instantly, any requests still in flight can fail outright.
Graceful shutdown handles this better.
The idea in code roughly looks like:
process.on("SIGTERM", async () => {
console.log("Shutdown signal received");
server.close(async () => {
await pool.end();
console.log("Server closed");
process.exit(0);
});
});
The precise implementation will differ depending on your framework.
But the underlying sequence stays consistent:
Stop accepting new requests
↓
Finish active requests
↓
Close database connections
↓
Exit
This matters even more once you're running several instances of the application at once.
Retry Logic Can Make Problems Worse
When a call to an external service fails, retrying feels like the obvious fix:
try {
await callService();
} catch {
await callService();
}
That looks harmless enough on its own.
But now picture a thousand requests all following that same pattern.
Suppose the dependency starts responding slowly.
Every one of those failed calls gets retried automatically.
The struggling dependency now receives even more load than before.
This pattern has a name: a retry storm.
Retry logic needs to be designed deliberately, factoring in things like:
- a cap on attempts
- exponential backoff
- jitter
- sensible timeouts
- idempotency guarantees
- circuit-breaking where it makes sense
Retrying isn't automatically a recovery mechanism.
In some cases, it's exactly what turns a minor hiccup into a full outage.
Why Production Bugs Feel So Different
A typical local bug boils down to something like:
I made a mistake.
A typical production incident tends to look more like:
Several individually reasonable things interacted badly.
As an illustration:
Traffic increases
↓
More API instances
↓
More database connections
↓
Database reaches connection limit
↓
Queries fail
↓
Requests retry
↓
More load
↓
System becomes even less stable
None of the individual lines involved necessarily look dangerous in isolation.
The real problem lives in how they interact under load.
This is exactly why backend engineering, over time, shifts focus away from isolated functions and toward the behavior of the system as a whole.
The Most Dangerous Sentence in Backend Development
Perhaps the riskiest phrase in all of backend work is:
"No one is going to do that."
No one is going to:
- fire off 10,000 requests in a burst
- upload a 500 MB file
- double-click the submit button
- send malformed JSON
- ask for a million rows at once
- keep using an outdated client
- hit the API directly instead of through the UI
- cause the external service to time out
- trigger two updates at the same moment
- restart the server mid-request
Except, eventually, someone does.
Sometimes by accident. Sometimes because of a bug elsewhere in the system. Sometimes because of a flaky network. Sometimes simply because your product started gaining real traction.
Production doesn't require bad actors to reveal shaky assumptions. Sheer scale is enough to expose them on its own.
Build for Failure, Not Just Success
A major shift in how experienced backend developers think is this: when you write a function, you shouldn't stop at asking what happens when it succeeds. You also need to ask what happens when it fails.
What happens when it succeeds?
What happens when it fails?
When you call the database, ask what happens if it's unreachable.
What if it's unavailable?
When you call Redis, ask what happens if it responds slowly.
What if it's slow?
When you send an email, ask what happens if the provider's request times out.
What if the provider times out?
When you upload a file, ask what happens if storage fails right after the database record has already been written.
What if storage fails after the database record is created?
When you create an order, ask what happens if the same request arrives twice.
What if the request is repeated?
When you process a payment, ask what happens if the client never gets the response back.
What if the client never receives the response?
When you deploy, ask what happens if the new version crashes on startup.
What if the new version crashes?
Asking these questions consistently changes how you design your architecture.
A Simple Mental Model for Production Backend Development
When building a backend feature, it helps to run it through five layers of thinking.
1. Correctness
Does the feature actually do what it's meant to do?
Create order → order actually gets created
2. Security
Could someone perform an action they shouldn't be allowed to?
User A → access User B's order
3. Performance
What happens as data volume or traffic grows substantially?
100 users → 10 million users
4. Reliability
What happens when the systems this feature depends on go down?
Database unavailable
5. Operability
If something breaks, can you actually diagnose and fix it?
Request failed at 03:42:17
Running through these five layers tends to surface a surprising number of issues before they ever reach production.
From "It Works" to "It Survives"
There's a real gap between software that simply works and software that can survive contact with the real world.
A local build only has to prove one thing:
feature works
A production build has to prove several:
feature works
feature fails safely
feature handles load
feature protects data
feature can be monitored
feature can recover
feature can be deployed
feature can be rolled back
That's a considerably higher bar to clear. It's also what makes backend engineering genuinely interesting as a discipline.
The 10 Mistakes Again
Here's a quick recap of everything covered.
1. Missing environment configuration
Don't assume that whatever setup exists on your laptop will also exist in production. Check required environment variables at startup and fail loudly if they're missing.
2. Hardcoding localhost
Inside a container, localhost only refers to that container itself. Rely on proper service discovery and configuration that adapts per environment.
3. Creating database connections per request
Use a connection pool, and account for the total number of connections across every running instance of your app.
4. Returning everything from the database
Avoid reaching for SELECT * out of habit. Send back only the fields your API consumers actually need.
5. Ignoring indexes
A query that performs fine against a thousand rows can fall apart against millions. Test with data volumes that resemble production.
6. Treating local storage as permanent
Containers, and many cloud platforms in general, offer no guarantee that local files will persist. Route user-uploaded content to storage built for durability.
7. No rate limiting
Clients don't police how often they call your API. Put limits around endpoints that are sensitive or costly to run.
8. Long-running synchronous requests
Heavy processing usually belongs in a background job, not something an HTTP request sits around waiting for.
9. Trusting client input
Validate everything again at the backend boundary. Frontend checks alone are never sufficient.
10. Confusing "process alive" with "application healthy"
Lean on health checks, monitoring, structured logs, metrics, and alerts that actually mean something.
Final Thoughts
What's striking about production issues is that almost none of them look alarming at first glance.
A hardcoded localhost doesn't look like a problem. A missing index doesn't look like a problem. Opening a fresh database connection per request doesn't look like a problem. A missing timeout doesn't look like a problem. An API route with no limits doesn't look like a problem. Writing a file straight to disk doesn't look like a problem.
Not until real users show up.
That's when these small, seemingly harmless assumptions start colliding with one another. And suddenly you're staring at:
slow APIs
database connection exhaustion
memory spikes
timeouts
failed deployments
missing files
duplicate payments
security issues
The point isn't to anticipate every conceivable failure in advance. That's not achievable. The point is to stop building systems on the premise that everything will always go smoothly.
Because it won't.
Networks fail. Databases slow down. Containers get restarted. Users send input you never expected. Downstream dependencies go offline. Traffic spikes without warning. Servers run out of memory. Requests get duplicated. Deployments go sideways.
None of that has to be catastrophic, as long as your system was designed with those possibilities in mind from the start.
What really separates a backend that merely runs on localhost from one that holds up in production isn't the programming language you chose. It's the mindset behind the design.
On localhost, the question is simply: does it work?
In production, the question that matters is: what happens when it doesn't?
That second question, asked over and over, teaches you far more about backend engineering than any framework or tool ever will.
If you're putting together your first production system, resist the urge to fix everything at once. Start small: get configuration handling right, add connection pooling, validate every input, build indexes based on how you actually query the data, handle timeouts explicitly, store files in the right place, add meaningful logging, monitor what matters, and think through failure scenarios as you go. Then keep repeating that process.
Production readiness isn't a box you check once and move on from. It's a habit you build over time as a developer.
And little by little, that becomes the difference between a system that only says "it works on my machine" and one that actually holds up when it matters.