Writing JavaScript You Won’t Hate Reading Six Months Later
I spent last Tuesday staring at a 4,000-line Express controller. It was supposed to be a simple backend for a note-taking app. Instead, it was a nesting doll of callbacks, mutated variables, and deeply nested if statements that made me want to close my laptop and walk into the ocean.
Well, that’s not entirely accurate — people do talk about “clean code” like it’s some philosophical mountain you have to climb. But really, it’s just writing JavaScript so the next person — usually you, heavily caffeinated and under a deadline — can figure out what the hell is going on without running a debugger for three hours.
And over the last few years, I’ve completely changed how I write JS. I stopped caring about clever one-liners. I started caring about predictability. Here is what actually works in practice.
Functions: Stop the Parameter Madness
I used to write functions that took five or six arguments. Then I’d call them and completely forget the order. Was it (user, id, token) or (id, user, token)?
Just use object destructuring. Always. If a function takes more than two arguments, it should take an object. This gives you named parameters by default and makes your code self-documenting.
// The old way: I will 100% mess up this order
function createNote(title, content, authorId, isPublished, tags) {
// ...
}
createNote("My Note", "Hello world", "user_99", true, ["js"]);
// The sane way
function createNote({ title, content, authorId, isPublished = false, tags = [] }) {
// You can set defaults right in the signature.
// The order doesn't matter anymore.
}
createNote({
authorId: "user_99",
title: "My Note",
content: "Hello world",
tags: ["js"],
isPublished: true
});
This probably saved my ass on a project last month when we had to add an optional workspaceId to 40 different API calls. I just added it to the destructured object. No breaking changes. No hunting down every function call to append a null value.
APIs and Async: Escaping Try/Catch Hell
If your API route handlers are 80% try/catch blocks, you’re burying the actual logic under error-handling boilerplate.
But if you’re stuck on an older codebase or writing generic async utilities, you need a wrapper. And I got so tired of typing the exact same error logic that I use this higher-order function everywhere.
// A simple wrapper to catch async errors and pass them to Express 'next'
const asyncHandler = (fn) => (req, res, next) => {
return Promise.resolve(fn(req, res, next)).catch(next);
};
// Now your API routes actually look like routes
app.post('/api/notes', asyncHandler(async (req, res) => {
const { title, content } = req.body;
// No try/catch needed. If db.insert fails, the wrapper catches it.
const newNote = await db.notes.insert({ title, content });
res.status(201).json({ data: newNote });
}));
Keep your API handlers incredibly thin. They should do exactly three things: parse the request, call a service function, and return the response. That’s it. If you have database queries mixed with HTTP header logic, you’re building a trap for yourself.
DOM: Stop Using HTML as a Database
Switching gears to the frontend. The absolute biggest mess I see in vanilla JS or lightweight client-side code is using the DOM to store state.
Reading .innerHTML or parsing data-* attributes to figure out what your application should do next is a massive anti-pattern. The DOM is a reflection of your state, not the source of truth.
// ❌ TERRIBLE: Reading from the DOM to determine logic
document.querySelector('#save-btn').addEventListener('click', () => {
const isEditing = document.querySelector('#editor').classList.contains('active');
const noteId = document.querySelector('#note-title').getAttribute('data-id');
if (isEditing) {
// do something...
}
});
// ✅ SANE: Keep state in JS, render to the DOM
const state = {
isEditing: false,
currentNoteId: null
};
document.querySelector('#save-btn').addEventListener('click', () => {
// Logic relies on JS state, completely decoupled from the UI
if (state.isEditing && state.currentNoteId) {
saveNote(state.currentNoteId);
}
});
When you separate your state from your UI, you can test your logic without needing a browser environment. You just test the state object.
The “Clean Code” Performance Trap
And here’s a specific gotcha I ran into recently that completely changed my perspective on abstraction.
I got obsessed with making everything pure, declarative functions. We had a background job processing a massive array of analytics events—about 85,000 records. To keep the code “clean,” I chained .map(), .filter(), and .reduce().
Why? Because every time you chain an array method, JavaScript creates a completely new array in memory. For 85,000 records, I was creating and destroying hundreds of thousands of objects in milliseconds.
I rewrote it using a basic, ugly for...of loop.
// It's not as pretty, but it doesn't melt the server
const processedStats = {};
for (const event of massiveEventArray) {
if (event.type !== 'click') continue; // filter
const normalizedId = event.userId.toLowerCase(); // map
// reduce
if (!processedStats[normalizedId]) {
processedStats[normalizedId] = 0;
}
processedStats[normalizedId]++;
}
The lesson? Clean code doesn’t mean ignoring how the V8 engine actually works. Sometimes a basic loop is exactly what you need. Don’t sacrifice your server’s RAM just because array chaining looks better in a pull request.
Write code for humans first, absolutely. But when you hit a bottleneck, don’t be afraid to write something a little “ugly” if it gets the job done efficiently. Just leave a comment explaining exactly why you did it.
Node.js Documentation Node.js GitHub Repository MDN Web Docs – JavaScript Functions