JavaScript Best Practices

6 minute read

You’ve covered the language end to end. This final chapter is a field guide — the conventions, patterns, and trade-offs that turn working JavaScript into maintainable JavaScript. Treat it as a checklist you can return to before you ship.

1. Variables & Types

  • Default to const. Use let only when you genuinely need to reassign. Never use var.
  • Prefer specific names (activeUsers) over generic ones (data, info, temp).
  • Beware implicit coercion. Use === / !==, and explicit conversions: Number(x), String(x), Boolean(x).
  • Use ?? for nullish defaults, || only when falsy (0, "") should also trigger the fallback.
const port    = config.port    ?? 3000;   // ✅ keeps 0
const verbose = config.verbose || false;  // ❌ "" or 0 → false
  • Use template literals for any non-trivial string assembly — never +.

2. Functions

  • Keep them small (one screen, one job). Long functions are the #1 source of bugs.
  • Pure where possible — same input → same output, no side effects. Pure functions test trivially.
  • Default parameters beat || fallbacks.
  • Avoid mutable defaults in shared closures (function (arr = []) {} is fine — fresh array each call).
  • Arrow functions for callbacks; regular functions or methods when you need this or hoisting.
  • No arguments in modern code — use (...args) rest.
// ✅
function send({ to, subject, body, retries = 3 } = {}) { /* … */ }

// ❌
function send(to, subject, body, retries) {
  retries = retries || 3;
  /* … */
}

3. Async Code

  • Always await — never leave a promise floating without handling rejection.
  • Use try/catch around await, or a .catch on the chain.
  • Parallelise independent calls with Promise.all (or allSettled for partial success).
  • Sequence dependent calls intentionally — sequential awaits on independent work is a silent slowdown.
  • Always add a timeout to outbound HTTP via AbortSignal.timeout(ms).
  • Never use async inside forEach — forEach ignores the returned promise. Use for…of or Promise.all(map(...)).
// ❌ silently sequential, ignores errors
items.forEach(async (i) => await save(i));

// ✅ parallel and observed
await Promise.all(items.map(save));

4. Equality, Truthiness, and Numbers

  • === / !== for everything — including comparing to null (yes, x == null is the one legitimate use of ==, but x === null || x === undefined is even clearer).
  • Remember the falsy values: false, 0, "", null, undefined, NaN, 0n.
  • For floats: Math.abs(a - b) < 1e-9 instead of a === b.
  • For very large integers: use BigInt (123n).
  • Use Number.isFinite(x) and Number.isInteger(x) — never the global isNaN/isFinite (they coerce).

5. Arrays and Objects

  • Use spread {...obj} / [...arr] instead of Object.assign / concat for cloning.
  • Use destructuring for named-arg style and for unpacking responses.
  • Immutable updates win in shared state: produce new arrays/objects instead of mutating in place (map, filter, { ...obj, key: v }).
  • Object.freeze your constants so accidents become errors.
  • For dictionaries with unknown keys, prefer Map over plain objects (no prototype pollution).
// ✅
const next = { ...prev, name: "Ada" };

// ❌
prev.name = "Ada";   // mutates shared state

6. Modules

  • Use ESM (import / export). Avoid mixing with CommonJS in the same package.
  • Prefer named exports over defaults — better refactor, grep, and tree-shake.
  • Keep modules small and focused — one concept per file.
  • Use import() for code-splitting on-demand features.
  • Watch out for barrel files that hurt tree-shaking; deep-import in hot paths.

7. Classes vs Functions

  • Use a class when you have state + behaviour bundled.
  • Use plain functions + closures for everything else.
  • Use #private fields — never _private conventions — for true encapsulation.
  • Prefer composition over inheritance. Keep extends chains short.
  • Use getters/setters to wrap validation around access.

8. Error Handling

  • Always throw new Error(...) (or a subclass). Never strings.
  • Define typed error classes for branching reactions (ValidationError, NotFoundError).
  • Catch at the boundary where you can do something useful — not in every helper.
  • Re-throw with { cause } to add context without losing the original.
  • Register global handlers (unhandledrejection, error) for reporting — not for recovery.
  • Don’t return errors as values (Go-style) in JavaScript — use exceptions; that’s what the language is built around.

9. The DOM & Events

  • Cache DOM lookups; don’t querySelector in a loop.
  • Use event delegation for many child elements.
  • Always remove listeners when components unmount (AbortController is ideal).
  • Prefer dataset over arbitrary attributes for component-meta.
  • Batch reads/writes to avoid layout thrash (read all → write all).
const ctrl = new AbortController();
btn.addEventListener("click", onClick, { signal: ctrl.signal });
// later
ctrl.abort();

10. Performance

  • Measure first. Profile with DevTools / performance.now() — don’t optimise from intuition.
  • Avoid the classic O(n²) — array.includes inside a loop becomes Set.has.
  • Debounce input handlers, throttle scroll/resize.
  • Lazy-load heavy modules with dynamic import().
  • Stream large data via async generators instead of buffering arrays.
  • Avoid layout thrash in DOM code (group reads then writes).

11. Security

  • Never innerHTML user content. Use textContent or proper escaping.
  • Avoid eval and the Function constructor on dynamic input.
  • Use crypto.randomUUID() / crypto.getRandomValues — never Math.random() for tokens or IDs.
  • Validate at the boundary — never trust input from URL, form, network, or storage.
  • Escape user-supplied strings before regex via a helper.
  • Set sensible HTTP headers: CSP, X-Content-Type-Options, etc. (server-side concern).
  • Treat dependencies as attack surface — audit, pin, and limit.

12. Code Style & Tooling

Tool Job
Prettier Auto-format. Stop arguing about commas.
ESLint Lint, catch bugs, enforce conventions.
TypeScript (or JSDoc types) Static safety, better IDE.
Vitest / Jest Unit + integration tests.
Playwright / Cypress End-to-end browser tests.
npm audit / Snyk / Dependabot Catch vulnerable deps.

Settle these once at the project root and stop re-deciding per PR.

13. Testing

  • Test behaviour, not implementation. “What does the user / caller see?” — not “did this method get called?”
  • One assertion per test (most of the time). Failures tell you the cause immediately.
  • Arrange / Act / Assert structure.
  • Use real timers and real promises when you can — mock the boundary, not your own code.
  • Cover the error paths — many bugs hide in catch blocks no one ever ran.

14. Naming and Comments

  • Code is read far more than it is written. Names matter more than cleverness.
  • Use verbs for functions (fetchUser), nouns for data (user, users), adjectives/is/has for booleans (isReady, hasError).
  • Comment the why, not the what.
  • Delete commented-out code — git remembers.

15. A “First Hour on a New Project” Checklist

  • Node version pinned (.nvmrc / engines)
  • ESM enabled ("type": "module")
  • Prettier + ESLint configured and running in CI
  • Tests with one CI command (npm test)
  • Strict mode everywhere (modules are by default)
  • No var, no ==, no implicit globals
  • All external calls have a timeout
  • All awaited promises have a try/catch or a chain .catch
  • No innerHTML with user content
  • No Math.random() for security-sensitive values
  • Errors are real Error instances with useful messages
  • No deeply nested extends
  • No barrel files hurting tree-shaking in hot paths
  • No floating promises (linter rule on)

16. Recurring Mental Models

  • “Everything in JavaScript is either a primitive or an object.”
  • “A function is a value.” First-class functions enable callbacks, HOFs, closures, composition.
  • “Reference equality, not value equality.” {a:1} === {a:1} is false.
  • “The event loop is single-threaded.” Long sync work blocks the UI. Break it up or move to a Worker.
  • “Promises are values.” They represent eventual completion — store them, pass them, await them.
  • “Modules are live.” Imports see the latest value, not a snapshot.

17. When in Doubt

  • Choose the boring option. Boring code is the easiest to change later.
  • Reach for standard library (URL, URLSearchParams, Intl, crypto, structuredClone, AbortController) before adding a dependency.
  • Optimise clarity first, performance when measured, cleverness never.

What’s Next?

That’s the JavaScript tutorial — from your first console.log to streaming async generators and production-grade defaults. Bookmark this chapter; revisit it on your next code review.

Where to go from here:

  • Frameworks — apply these patterns to React, Vue, Svelte, or Angular.
  • Node.js — write CLIs, servers, and tools.
  • TypeScript — add a type layer that catches whole categories of bugs at edit time.
  • Web Platform deep-dives — Web Components, Service Workers, WebRTC, WebGPU, Workers.
  • Testing — Vitest / Jest / Playwright to lock the behaviour in.
  • Performance — DevTools Performance panel, Lighthouse, Web Vitals.

Keep building. The best way to grow as a JS developer is to ship — then read your own code three months later and rewrite the parts that hurt.

← Previous: JS Iterators & Generators

Updated: