Table of Contents
Stop Writing “Clean” Code: JavaScript Best Practices for Systems That Don’t Break at 3 AM
In 2019, I nearly cost a fintech startup its Series B. I had implemented what I thought was a “clean” logging middleware for our Node.js gateway. It used a beautiful, recursive deep-clone utility to ensure we weren’t mutating request objects before they hit the downstream services. It worked perfectly in staging. In production, under a load of 4,000 requests per second, the V8 engine’s garbage collector couldn’t keep up with the object churn. The heap usage looked like a staircase to heaven until the pods hit their 2GB limit and were OOM-killed by the Kubelet. Every. Single. One.
I watched the 502 errors spike on the Grafana dashboard while my “elegant” code strangled the CPU. That was the day I stopped caring about how “pretty” my JavaScript looked and started caring about how it actually behaved in a Linux environment. Most “javascript best” guides focus on where to put your curly braces or why you should use arrow functions. This isn’t one of those guides. We’re going to talk about the trade-offs that actually matter when your code is running on a real server, with real memory constraints, and real users who don’t care about your functional programming purity.
1. The Async/Await Waterfall Trap
The most common performance killer in modern JavaScript isn’t a slow loop; it’s the accidental sequential execution of independent tasks. People treat await like a magic “make it sync” button without thinking about the event loop.
// The "Clean" but slow way
const user = await db.users.find({ id: userId });
const permissions = await api.stripe.getPermissions(user.stripeId);
const preferences = await redis.get(`prefs:${userId}`);
This is a disaster. You’ve just turned three non-blocking operations into a blocking sequence. If each call takes 100ms, your total latency is 300ms. In a high-concurrency environment, this holds the request context in memory three times longer than necessary. Use Promise.all or Promise.allSettled to saturate your I/O.
// The pragmatic way
const [user, preferences] = await Promise.all([
db.users.find({ id: userId }),
redis.get(`prefs:${userId}`)
]);
// Stripe needs the user object, so it stays sequential to that
const permissions = await api.stripe.getPermissions(user.stripeId);
Pro-tip: Never use
Promise.allif you have more than 10-15 concurrent network requests. You will saturate the connection pool and start seeingECONNRESETerrors. For bulk operations, use a p-limit or a similar concurrency-controlled queue.
2. Error Handling: Stop Using throw Like a Java Developer
In JavaScript, an unhandled exception in a Promise or an async function can crash the entire process if you haven’t set up a global listener. Even if you have, try/catch blocks are often used lazily. When you throw new Error('Something went wrong'), you lose the ability to differentiate between a “Domain Error” (the user provided a bad password) and a “System Error” (the database is down).
- Use Error Codes: Always attach a machine-readable code to your errors.
error.code = 'ERR_AUTH_FAILED'is infinitely better than parsingerror.message. - The “Result” Pattern: For high-frequency functions, consider returning an object like
{ data, error }instead of throwing. This forces the caller to handle the failure explicitly.
async function fetchUser(id) {
try {
const res = await fetch(`https://api.stripe.com/v1/customers/${id}`);
if (!res.ok) return { data: null, error: 'API_UNAVAILABLE' };
return { data: await res.json(), error: null };
} catch (e) {
return { data: null, error: 'NETWORK_FAILURE' };
}
}
This approach prevents the V8 engine from having to create a stack trace for expected failures. Stack traces are expensive. If you’re throwing 1,000 errors a second for validation failures, you’re burning CPU cycles on string manipulation that no one will ever read.
3. The Myth of “Clean” Dependency Management
The “javascript best” practice is often cited as “don’t reinvent the wheel.” This has led to the node_modules black hole. I once audited a project where lodash was imported just to use _.get(). In modern Node.js (v14+), we have optional chaining (?.). We don’t need a 24KB library to avoid a null pointer.
Every dependency you add is a liability. It’s a security risk (see the event-stream incident), a maintenance burden, and it slows down your CI/CD pipeline. Before running npm install, ask yourself: “Can I write this in 10 lines of vanilla JS?”
// Instead of importing 'ms'
const ONE_DAY_IN_MS = 86_400_000;
// Instead of 'uuid' (if you're on Node 14.17+)
const { randomUUID } = require('crypto');
const id = randomUUID();
If you must use a library, use npm audit, but don’t trust it blindly. It only catches known vulnerabilities. Use pkg-size to see what you’re actually shipping to your production containers. A 500MB container image takes longer to pull, longer to scan, and longer to deploy during a critical hotfix.
4. Memory Leaks: Closures and the “Hidden” Heap
JavaScript is garbage-collected, which makes developers lazy. The most common leak I see in the wild involves closures holding onto large objects longer than necessary. Consider a request-scoped logger that captures the req object.
function createLogger(req) {
return (msg) => {
console.log(`[${req.id}] ${msg}`);
};
}
app.get('/data', (req, res) => {
const log = createLogger(req);
// If this callback is stored somewhere globally...
globalEmitter.on('update', () => log('Updated'));
res.send('ok');
});
In the example above, the log function is a closure that keeps a reference to req. If that globalEmitter isn’t cleaned up, the req object (and everything attached to it, like large buffers or database connections) stays in memory forever. This is how you get a “slow leak” that only manifests after 48 hours of uptime.
Note to self: Use
WeakMapfor metadata. If the object key is garbage collected, the value in theWeakMapis too. It’s the only way to safely associate data with objects you don’t own.
5. The Event Loop is Not Your Playground
JavaScript is single-threaded. If you block the event loop, you block everything. I’ve seen developers try to process 10MB CSV files using JSON.parse() or complex regex on the main thread. While that’s happening, your /healthz endpoint won’t respond. Kubernetes will think your pod is dead and kill it.
If you have a CPU-intensive task, you have two choices:
- Offload it: Use
worker_threads. It’s been stable since Node 12. - Chunk it: Use
setImmediate()to break up the work and let the event loop breathe.
// Don't do this for large arrays
const processed = hugeArray.map(item => heavyComputation(item));
// Do this
async function processInChunks(items) {
const results = [];
for (const item of items) {
results.push(heavyComputation(item));
// Yield back to the event loop every 100 items
if (results.length % 100 === 0) await new Promise(setImmediate);
}
return results;
}
6. TypeScript: The Non-Negotiable Best Practice
If you are writing plain JavaScript for a production system in 2024, you are essentially writing code without a safety net while blindfolded. TypeScript isn’t about “type safety” in the academic sense; it’s about documentation and refactoring. The “javascript best” way to handle a codebase with more than two developers is TypeScript.
However, avoid the any type like the plague. any is a lie you tell yourself to make the compiler shut up. If you don’t know the type, use unknown and narrow it. If you use any, you’re just writing JavaScript with extra steps and no benefits.
// Bad: The 'any' virus
function handlePayload(payload: any) {
console.log(payload.user.id); // Runtime crash if user is missing
}
// Good: Defensive typing
function handlePayload(payload: unknown) {
if (payload && typeof payload === 'object' && 'user' in payload) {
// TypeScript now knows payload has a 'user' property
}
}
7. The “JSON.parse” Gotcha
Here is a real-world scenario: You’re consuming a message from a Kafka queue. The message is a 2MB JSON string. You call JSON.parse(message). For the next 15ms, your Node.js process is a brick. It cannot handle any other requests. If you’re doing this at scale, your P99 latency will look like a mountain range.
For large payloads, consider using a streaming parser like stream-json. Or, better yet, ask why you’re sending 2MB of JSON in a single message. Protobufs or MessagePack are often better alternatives for internal service-to-service communication, but that’s a different “YAML-hell” conversation.
8. Logging: Console.log is a Synchronous Sink
In a browser, console.log is fine. In Node.js, if you pipe your output to a file or a TTY, console.log is synchronous. If your app is logging 5,000 lines a second, your CPU is spending more time waiting for I/O to the terminal than it is processing business logic.
Use a proper logging library like pino or winston. pino is particularly good because it’s designed to be as low-overhead as possible. It logs in JSON format by default, which is what your ELK stack or Datadog agent wants anyway.
const logger = require('pino')();
// Fast, asynchronous, and structured
logger.info({ userId: '123', action: 'login' }, 'User logged in');
9. Environment Variables and Config
Stop using process.env directly in your business logic. It’s slow (accessing process.env involves a string lookup every time) and it makes testing a nightmare. Load your config once at startup, validate it with something like Zod, and freeze the object.
import { z } from 'zod';
const ConfigSchema = z.object({
STRIPE_KEY: z.string().startsWith('sk_'),
DB_URL: z.string().url(),
PORT: z.coerce.number().default(3000),
});
const config = ConfigSchema.parse(process.env);
Object.freeze(config);
export default config;
If STRIPE_KEY is missing, the app crashes immediately on startup. This is “Fail Fast” in action. It’s much better to have a pod fail to start than to have it start and then fail 20 minutes later when a user tries to checkout and the Stripe client receives undefined.
10. The Cost of Transpilation
We’ve spent years transpiling ES6 to ES5 so that IE11 users could browse our sites. It’s 2024. Unless you’re supporting legacy government systems, you can ship modern JS. Transpilation adds “bloat” and makes debugging harder because the stack traces don’t match your source code (even with sourcemaps, it’s a headache).
Target esnext in your tsconfig.json. Use native import/export. Node.js has supported ESM for years now. The “javascript best” practice is to stay as close to the metal as possible. Every layer of abstraction (Babel, Webpack, SWC) is another place for a bug to hide.
11. AbortController: The Forgotten Hero
If a user navigates away from a page or closes a connection, you should stop the work you’re doing for them. AbortController is now a standard part of the JS ecosystem (both browser and Node).
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000); // 5s timeout
try {
const res = await fetch('https://api.stripe.com/v1/charges', {
signal: controller.signal
});
} catch (err) {
if (err.name === 'AbortError') {
console.log('Request timed out or was cancelled');
}
}
Without this, your backend might keep churning on a heavy database query or a slow API call even though the client has already disconnected. This is “zombie work,” and it’s the silent killer of scalability.
12. Don’t Trust the “Benchmarks”
You’ll see blogs claiming that for loops are 10x faster than .forEach(). While technically true in a micro-benchmark, it almost never matters in a real application. What matters is the Big O complexity and the I/O. Optimizing a loop that runs 100 times while your database query takes 50ms is like worrying about the weight of your car’s floor mats when the engine is missing.
Focus on:
- Reducing the number of network round-trips.
- Indexing your database properly.
- Caching expensive computations in Redis.
- Avoiding large object allocations in hot paths.
JavaScript is fast enough for 99% of use cases. When it’s slow, it’s usually because you’re asking it to do something fundamentally inefficient, not because you used the “wrong” kind of loop.
The “javascript best” practices aren’t about following a style guide. They are about understanding that your code lives in a physical world of limited memory, finite CPU cycles, and unreliable networks. Stop trying to write “clever” code. Write boring code. Write predictable code. Write code that fails loudly and clearly. Your SREs will thank you at 3 AM, and your users will never know your name—which is the highest compliment a developer can receive.
Stop obsessing over syntax and start profiling your heap.
Related Articles
Explore more insights and best practices: