These aren’t beginner mistakes.
That’s what makes them worth writing about. Beginner mistakes disappear as developers gain experience. The mistakes in this article persist into professional codebases written by experienced developers because they look correct, they work correctly at small scale, and they only become expensive when the application grows into conditions that development never simulates.
I’ve reviewed enough production JavaScript to recognize these on sight. Not because the developers who wrote them were careless — they weren’t. Because these patterns have a specific characteristic: they’re invisible problems until the scale reveals them, at which point they’re expensive to fix because they’re woven into the codebase rather than isolated in one place.
Here’s what I keep finding and exactly why each one matters at scale.
1. Mutating Function Arguments
JavaScript passes objects and arrays by reference. A function that receives an object and modifies it directly is modifying the caller’s data — silently, without the caller’s knowledge or consent.
// Found in a professional codebase
function applyDiscount(order, discountPercent) {
order.items.forEach(item => {
item.price = item.price * (1 - discountPercent / 100);
});
order.discountApplied = true;
return order;
}
const originalOrder = { items: [{ price: 100 }], discountApplied: false };
const discountedOrder = applyDiscount(originalOrder, 10);
console.log(originalOrder.items[0].price); // 90 - original was mutated
console.log(originalOrder.discountApplied); // true - original was mutated
This works in development because tests typically create fresh objects for each test. It breaks in production when the same object is used in multiple contexts — displayed in one component, processed by another, tracked for analytics in a third. A discount applied in one context silently changes the price in every other context holding a reference to the same object.
// The correct version
function applyDiscount(order, discountPercent) {
return {
...order,
discountApplied: true,
items: order.items.map(item => ({
...item,
price: item.price * (1 - discountPercent / 100)
}))
};
}
Why it persists in professional codebases: Mutation is faster to write than copying. In isolation it’s undetectable. Only shared references reveal the problem — and shared references are rare in development, common in production.
2. Using Array Index as React Key
The React key prop exists to help React identify which items in a list have changed, been added, or been removed. Using array index as the key defeats this entirely when items can be reordered, filtered, or inserted.
// Found everywhere
function OrderList({ orders }) {
return (
<ul>
{orders.map((order, index) => (
<li key={index}> {/* Index key — looks fine, breaks at scale */}
<OrderItem order={order} />
</li>
))}
</ul>
);
}
When items are stable and the list never reorders or filters, index keys work. When users can sort, filter, search, or when new items are prepended — all common in production applications — index keys cause React to reuse the wrong DOM nodes. Input fields retain the previous item’s value. Animations play on the wrong elements. State from one item leaks into a different item’s position.
// The correct version
function OrderList({ orders }) {
return (
<ul>
{orders.map(order => (
<li key={order.id}> {/* Stable unique identifier */}
<OrderItem order={order} />
</li>
))}
</ul>
);
}
Why it persists in professional codebases: ESLint requires a key and index silences the warning immediately. The bug only appears when the list becomes interactive — filterable, sortable, pageable — which often happens after the initial implementation when nobody remembers to revisit the key strategy.
3. Floating Point Arithmetic in Financial Calculations
JavaScript uses IEEE 754 double-precision floating-point arithmetic. The consequence that surprises developers: 0.1 + 0.2 === 0.30000000000000004. Not approximately. Exactly that value, consistently, in every JavaScript runtime.
// Found in e-commerce codebases handling real money
function calculateTotal(items) {
return items.reduce((total, item) => total + item.price * item.quantity, 0);
}
const items = [
{ price: 0.10, quantity: 3 },
{ price: 0.20, quantity: 2 }
];
console.log(calculateTotal(items)); // 0.7000000000000001
// Display: "$0.70" after toFixed(2) - but intermediate calculations are wrong
For display purposes toFixed(2) masks the problem. For calculations that accumulate across many items, compare totals to thresholds, or feed into tax calculations, the floating point errors compound in ways that produce incorrect results — occasionally wrong by a cent, occasionally by more when many small errors accumulate.
// The correct version — work in integer cents
function calculateTotal(items) {
const totalCents = items.reduce((total, item) => {
return total + Math.round(item.price * 100) * item.quantity;
}, 0);
return totalCents / 100;
}
// Or use a decimal library for precision-critical calculations
import Decimal from 'decimal.js';
function calculateTotal(items) {
return items.reduce((total, item) => {
return total.plus(new Decimal(item.price).times(item.quantity));
}, new Decimal(0)).toNumber();
}
Why it persists in professional codebases: The errors are tiny and often invisible after rounding for display. They only matter in precision-critical contexts — financial calculations, tax computations, discount stacking — and only when they accumulate enough to cross a rounding boundary.
4. Unguarded Object Property Access
Modern JavaScript has optional chaining. Codebases written before optional chaining existed, or by developers who haven’t adopted it, access nested properties without guarding against null or undefined at any level.
// Found in codebases that handle API responses
function displayUserAddress(user) {
return `${user.profile.address.street}, ${user.profile.address.city}`;
// TypeError: Cannot read properties of undefined (reading 'address')
// When: user.profile is null, user has no profile, API returns unexpected shape
}
// Also found - the wrong guard
function displayUserAddress(user) {
if (user.profile) {
return `${user.profile.address.street}, ${user.profile.address.city}`;
// Still crashes if profile exists but address doesn't
}
return 'No address';
}
API responses change. Optional fields are sometimes present and sometimes absent. Users complete profiles partially. The nested property access that works for complete data crashes on incomplete data — which is rare in development with carefully crafted test data and common in production with real users.
// The correct version
function displayUserAddress(user) {
const street = user?.profile?.address?.street;
const city = user?.profile?.address?.city;
if (!street || !city) return 'No address provided';
return `${street}, ${city}`;
}
Why it persists in professional codebases: Development test data is usually complete. The partially-complete user profile, the API response missing an expected field, the null returned instead of an empty object — these appear in production with real users and real API variability, not in development with controlled data.
5. Event Listener Accumulation
Adding event listeners without removing them when they’re no longer needed creates listeners that accumulate over the lifetime of a single-page application — growing with every navigation, every component mount, every modal open.
// Found in non-React JavaScript applications
class SearchComponent {
constructor() {
this.searchInput = document.getElementById('search');
this.init();
}
init() {
// Called every time the component initializes
window.addEventListener('keydown', this.handleKeydown.bind(this));
document.addEventListener('click', this.handleOutsideClick.bind(this));
// Never removed — accumulates on every init() call
}
}
// Every time SearchComponent is instantiated, two more listeners are added
// After 50 searches: 100 keydown listeners, 100 click listeners
// Each fires on every keypress and click
The symptom in production: the application slows down gradually over extended use. Features that fire on global events — keyboard shortcuts, outside-click handlers, scroll listeners — fire increasingly often as duplicate listeners accumulate. Memory grows without bound.
// The correct version
class SearchComponent {
constructor() {
this.searchInput = document.getElementById('search');
// Bind once so the same reference can be removed
this.boundHandleKeydown = this.handleKeydown.bind(this);
this.boundHandleOutsideClick = this.handleOutsideClick.bind(this);
this.init();
}
init() {
window.addEventListener('keydown', this.boundHandleKeydown);
document.addEventListener('click', this.boundHandleOutsideClick);
}
destroy() {
window.removeEventListener('keydown', this.boundHandleKeydown);
document.removeEventListener('click', this.boundHandleOutsideClick);
}
}
Why it persists in professional codebases: In short sessions with page refreshes, accumulated listeners reset. SPAs with long session durations accumulate them. The bug is undetectable in development with short test sessions and obvious in production with users who keep the application open all day.
6. Synchronous Operations Inside Loops
A single synchronous database query, API call, or file read is fast. The same operation inside a loop executing hundreds of times is a performance bottleneck waiting for scale to reveal it.
// Found in data processing endpoints
async function enrichOrders(orderIds) {
const enrichedOrders = [];
for (const orderId of orderIds) {
// Sequential — each awaits before the next starts
const order = await database.getOrder(orderId);
const customer = await database.getCustomer(order.customerId);
const product = await database.getProduct(order.productId);
enrichedOrders.push({ order, customer, product });
}
return enrichedOrders;
}
// 100 orders = 300 sequential database queries
// 1000 orders = 3000 sequential database queries
// Latency scales linearly with volume
At ten orders this is fast. At a thousand orders it’s a timeout waiting to happen. The queries are independent — each order’s data doesn’t depend on another order’s data — but they execute sequentially rather than concurrently.
// The correct version — concurrent where possible
async function enrichOrders(orderIds) {
// Fetch all orders concurrently
const orders = await Promise.all(
orderIds.map(id => database.getOrder(id))
);
// Fetch all related data concurrently
const [customers, products] = await Promise.all([
Promise.all(orders.map(o => database.getCustomer(o.customerId))),
Promise.all(orders.map(o => database.getProduct(o.productId)))
]);
return orders.map((order, i) => ({
order,
customer: customers[i],
product: products[i]
}));
}
// Better still - single query fetching all related data at once
async function enrichOrders(orderIds) {
const [orders, customers, products] = await Promise.all([
database.getOrdersByIds(orderIds),
database.getCustomersByOrderIds(orderIds),
database.getProductsByOrderIds(orderIds)
]);
// Join in application layer
}
Why it persists in professional codebases: Sequential async code reads naturally and works correctly. The performance problem is invisible at the data volumes used during development and appears suddenly when production data volumes scale.
7. Missing Cleanup in useEffect
React’s useEffect runs after every render where dependencies change. When a useEffect starts an async operation — a fetch request, a subscription, a timer — and the component unmounts before the operation completes, the completion callback runs on an unmounted component.
// Found in React codebases handling navigation
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
// No cleanup — runs on unmounted component if user navigates away
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data)); // Runs even if component is gone
}, [userId]);
return user ? <ProfileCard user={user} /> : <Spinner />;
}
When a user navigates away before the fetch completes, the component unmounts. The fetch continues. The .then callback calls setUser on an unmounted component — generating a React warning in development and a memory leak in production as the component can't be garbage collected while the fetch holds a reference.
// The correct version
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => {
if (!cancelled) setUser(data);
})
.catch(err => {
if (err.name !== 'AbortError') console.error(err);
});
return () => {
cancelled = true;
controller.abort(); // Cancel the network request too
};
}, [userId]);
return user ? <ProfileCard user={user} /> : <Spinner />;
}
Why it persists in professional codebases: The React warning only appears in development mode and is easy to dismiss. The memory leak in production is gradual and doesn’t cause obvious failures — just slowly growing memory consumption over extended use.
8. Incorrect Dependency Arrays in useCallback and useMemo
useCallback and useMemo memoize values to prevent unnecessary recalculation or recreation. An incorrect dependency array — missing dependencies — produces stale closures that reference outdated values without the hook knowing to recalculate.
// Found in performance-optimized React components
function OrderProcessor({ orders, taxRate }) {
// taxRate missing from dependencies — stale closure
const calculateTotal = useCallback((order) => {
return order.items.reduce((sum, item) => sum + item.price, 0) * (1 + taxRate);
}, []); // Empty deps — never recalculates even when taxRate changes
return <OrderList orders={orders} calculateTotal={calculateTotal} />;
}
When taxRate changes — the user selects a different tax jurisdiction, an admin updates the rate, a feature flag changes the calculation — calculateTotal still uses the original taxRate from the first render. The calculation is wrong. The dependency array told React nothing changes, so React never recalculates.
// The correct version
function OrderProcessor({ orders, taxRate }) {
const calculateTotal = useCallback((order) => {
return order.items.reduce((sum, item) => sum + item.price, 0) * (1 + taxRate);
}, [taxRate]); // Recalculates when taxRate changes
return <OrderList orders={orders} calculateTotal={calculateTotal} />;
}
The eslint-plugin-react-hooks exhaustive-deps rule catches most of these. The mistake persists in codebases where the eslint rule is disabled — often because fixing it correctly requires restructuring code and disabling the rule is faster in the moment.
Why it persists in professional codebases: Empty dependency arrays are sometimes correct — for functions that genuinely depend on nothing that changes. The same pattern that’s correct for stable callbacks is incorrect for callbacks that close over props or state. The difference isn’t visible in the code structure — only in whether the closed-over values ever change.
9. String Concatenation for SQL and HTML
Building SQL queries or HTML strings through string concatenation with user-supplied values is an injection vulnerability that should be extinct and isn’t.
// Found in Node.js backends — still, in 2024
async function getUserOrders(userId, status) {
// SQL injection — userId or status could contain SQL
const query = `SELECT * FROM orders WHERE user_id = ${userId} AND status = '${status}'`;
return await database.query(query);
}
// Also found in template rendering
function renderUserBio(user) {
// XSS - user.bio could contain script tags
return `<div class="bio">${user.bio}</div>`;
}
These are known vulnerabilities with known fixes that have existed for decades. They still appear in professional code because concatenation is the path of least resistance when building dynamic strings and the security implication requires thinking adversarially about input.
// Parameterized queries — the correct approach
async function getUserOrders(userId, status) {
const query = 'SELECT * FROM orders WHERE user_id = $1 AND status = $2';
return await database.query(query, [userId, status]);
}
// Safe DOM creation - never innerHTML with user content
function renderUserBio(user) {
const div = document.createElement('div');
div.className = 'bio';
div.textContent = user.bio; // textContent never executes scripts
return div;
}
Why it persists in professional codebases: The vulnerability requires an adversarial input to exploit. Internal tools, admin panels, and applications where all users are trusted see these patterns survive code review because reviewers think “nobody would send a malicious input here.” Trust assumptions change when applications grow and user populations expand.
10. Treating console.error as Error Handling
Logging an error to the console is not error handling. It’s error acknowledgment. The difference is whether the application recovers gracefully or continues in a broken state while the error appears in a console nobody is watching.
// Found in production code — error handling theater
async function loadDashboardData() {
try {
const [analytics, orders, inventory] = await Promise.all([
fetchAnalytics(),
fetchOrders(),
fetchInventory()
]);
return { analytics, orders, inventory };
} catch (error) {
console.error('Dashboard load failed:', error);
// Returns undefined — caller receives undefined
// Dashboard renders with undefined data
// Components crash accessing undefined.property
// Or render empty with no explanation to the user
}
}
The error is logged. The function returns undefined. The caller doesn't receive data it expected. Downstream code crashes on undefined or renders an empty state with no explanation. The user sees a broken dashboard. The developer sees a console error that production monitoring may not capture.
// Actual error handling
async function loadDashboardData() {
try {
const [analytics, orders, inventory] = await Promise.all([
fetchAnalytics(),
fetchOrders(),
fetchInventory()
]);
return {
success: true,
data: { analytics, orders, inventory }
};
} catch (error) {
// Report to monitoring — not just console
errorMonitoring.captureException(error, { context: 'loadDashboardData' });
// Return recoverable state — not undefined
return {
success: false,
error: 'Dashboard data unavailable',
data: { analytics: null, orders: [], inventory: [] }
};
}
}
// Caller handles both cases
const result = await loadDashboardData();
if (!result.success) {
showErrorBanner(result.error);
}
renderDashboard(result.data); // Always receives valid shape
Why it persists in professional codebases: console.error satisfies the feeling of handling an error — something acknowledged the problem. It satisfies linting rules that require catch blocks to do something. It only reveals itself as inadequate when the error occurs in production and the application behaves unexpectedly with no visible explanation.
The Scale Threshold
Every mistake here has a scale threshold below which it’s invisible.
Mutating arguments is fine with one caller. It’s a bug with ten components sharing references. Index keys are fine with static lists. They’re broken with sortable, filterable lists. Floating point errors are invisible with two items. They accumulate with a hundred. Sequential async operations are fast with ten records. They’re timeouts with ten thousand.
Development conditions keep applications below most of these thresholds. Production crosses them. The mistakes were always there — invisible until the scale revealed them.
The practice that catches them before scale does: code review with the specific question of what happens at ten times current scale. Not whether the code is correct now — whether it’s correct at the scale the application will reach.
Most of these mistakes are obvious at ten times scale. They’re invisible at current scale. Asking the scale question during review is the only way to find them before production does.
If this made you look at your own codebase with fresh eyes — follow for more. I write about the JavaScript patterns that look harmless until they don’t.
Comments
Loading comments…