The this keyword behaves unexpectedly in callbacks, breaking code that works
in simple tests. Unhandled promise rejections crash production silently. Type
coercion makes == comparisons return surprising results. Callback hell creates
unmaintainable code that's impossible to debug. These issues run without errors
until specific conditions trigger failures.
This checklist helps catch what matters: this binding errors, async handling
mistakes, type coercion bugs, and code that fights modern JavaScript instead of
leveraging ES6+ features effectively.
Quick Reference Checklist
Use this checklist as a quick reference during code review. Each item links to detailed explanations below.
Modern Syntax & Features
constandletused instead ofvar- Arrow functions
=>for callbacks and functional operations - Template literals for string interpolation:
`Hello ${name}` - Destructuring extracts values:
const { name, age } = user - Spread operator copies arrays/objects:
{ ...obj, newProp: value } - Default parameters instead of
||fallbacks:function greet(name = 'Guest') - Rest parameters for variable arguments:
function sum(...numbers) - Object property shorthand:
{ name }instead of{ name: name }
Async Patterns
- Promises used instead of callbacks for async operations
async/awaitpreferred over raw promises for readability- Promise rejections handled with
.catch()or try-catch Promise.all()for parallel operations, not sequential awaitsPromise.allSettled()when all results needed regardless of failures- No
asyncfunctions withoutawait(misleading) - Error handling wraps async calls:
try { await fetch() } catch (e) {} - Unhandled rejections logged or monitored
Common Pitfalls
===used instead of==for comparisons!==used instead of!=for inequality- Array methods return new arrays (immutable):
map(),filter()vspush(),splice() thisbinding explicit: arrow functions orbind()- Closures used intentionally, not accidentally capturing variables
typeofchecks before operations:typeof value === 'string'nullvsundefinedunderstood and handled correctly- Numbers validated before arithmetic:
Number.isNaN(),Number.isFinite()
Functions & Scope
- Pure functions without side effects when possible
- Function parameters limited (prefer objects for many parameters)
- Single responsibility: functions do one thing well
- Early returns reduce nesting
- No modifying parameters (creates hidden side effects)
- Closures clean up resources when needed
- IIFE used only when actually needed (rare in modern code)
- No function hoisting relied upon for control flow
Arrays & Objects
- Array methods used functionally:
map(),filter(),reduce() - Mutation avoided: prefer new arrays/objects
- Optional chaining for nested access:
user?.profile?.email - Nullish coalescing for defaults:
value ?? fallback - Object spread preserves immutability:
{ ...obj, updated: true } - Array destructuring for multiple returns:
const [first, second] = array Object.keys(),Object.values(),Object.entries()for iterationArray.from()for array-like objects, not manual loops
Modules & Organization
- ES6 modules used:
import/export - Named exports for multiple exports, default for single export
- No mixing default and named exports from same module
- Import statements at top of file
- No circular dependencies between modules
- Side effect imports explicit:
import './styles.css' - Dynamic imports for code splitting:
import('./module').then() - Module names clear and descriptive
Modern JavaScript Syntax
const and let replaced var for good reasons. const prevents
reassignment, making code easier to reason about—when we see const, we know
the binding won't change. let provides block scope, eliminating the confusing
function scope behavior of var. When we see var in code review, we suggest
const for values that don't change and let for those that do. The only
remaining use for var is intentionally creating function-scoped variables,
which is rarely needed.
Arrow functions provide concise syntax and lexical this binding. For callbacks
and functional operations, array.map(x => x * 2) reads clearer than
traditional function syntax. More importantly, arrow functions inherit this
from the surrounding scope, eliminating the common mistake of losing this in
callbacks. When we see .bind(this) or storing this in variables, arrow
functions often eliminate the need.
Template literals make string interpolation readable. Instead of concatenation
like 'Hello ' + name + '!', we write `Hello ${name}!`. This improves
readability for complex strings and automatically calls toString() on values.
Template literals also support multi-line strings without escape sequences,
useful for HTML or other formatted text.
Destructuring extracts values from arrays and objects concisely. The pattern
const { name, age } = user extracts properties into variables without
repetitive user.name access. Array destructuring
const [first, second] = array provides clean syntax for multiple return
values. When we see code manually extracting properties one by one,
destructuring improves clarity.
Spread operator ... copies and merges arrays and objects.
{ ...obj, updated: true } creates a new object with obj's properties plus the
update, leaving obj unchanged. This immutable pattern prevents bugs from
unexpected mutations. Array spread [...array1, ...array2] concatenates without
modifying originals. When we see manual property copying or array concatenation,
spread syntax simplifies the code.
Default parameters handle optional arguments better than || checks. The
pattern function greet(name = 'Guest') provides a default without the
problematic behavior of name = name || 'Guest' which treats empty string as
falsy. Default parameters work only for undefined, not for falsy values, which
matches typical intent.
Async Programming Patterns
Promises represent asynchronous operations more compositionally than callbacks.
Instead of callback hell where each async operation nests deeper, promises chain
with .then():
fetch(url).then(response => response.json()).then(data => process(data)). This
flattens code structure and makes error handling consistent with .catch().
When we see heavily nested callbacks, promises improve readability.
async/await makes asynchronous code read like synchronous code. The pattern
const data = await fetch(url).then(r => r.json()) looks synchronous but
doesn't block. This dramatically improves readability compared to promise
chains. When reviewing async code, we verify await appears in async functions
and that errors are handled with try-catch.
Unhandled promise rejections silently fail unless caught. Every promise chain
needs .catch() or the await must be in a try-catch. Code that creates promises
without error handling creates bugs that are hard to diagnose because failures
don't surface. When we see promises without error handling, that's a critical
issue requiring immediate attention.
Promise.all() runs operations in parallel, while sequential awaits run
serially. The pattern await Promise.all([fetch(url1), fetch(url2)]) starts
both fetches simultaneously and waits for both. Sequential
await fetch(url1); await fetch(url2) waits for each to complete before
starting the next, taking twice as long. When operations don't depend on each
other, Promise.all improves performance.
Promise.allSettled() waits for all promises to settle, whether fulfilled or
rejected. Unlike Promise.all which rejects if any promise rejects, allSettled
returns all results. This proves useful when we need outcomes from all
operations regardless of failures. When partial results matter, allSettled
prevents losing successful results due to one failure.
Async functions without await mislead readers. Declaring async function but
never awaiting suggests misunderstanding. The async keyword makes the function
return a promise even without await, which might not be intended. When we see
async functions without await, we verify the promise return is intentional.
Common JavaScript Pitfalls
Strict equality === prevents type coercion surprises. The comparison
'5' == 5 evaluates to true because == coerces types. This creates
hard-to-find bugs when mixed types appear unexpectedly. === compares without
coercion, making comparisons predictable. When we see == in code review, we
suggest === unless there's specific reason for type coercion.
Array methods like map() and filter() return new arrays and don't mutate the
original. This immutable pattern prevents bugs from unexpected mutations.
Mutation methods like push(), splice(), and sort() modify the original
array, which can cause problems when the array is shared or used elsewhere. When
reviewing array manipulation, we verify whether mutation or immutability is
intended and that the chosen method matches.
this binding confuses developers because it depends on how functions are
called, not where they're defined. Arrow functions solve this by using lexical
this. Traditional functions need explicit binding with bind() or calling
with specific context. When we see this in non-arrow functions, we verify the
binding is correct for the intended usage.
Closures capture variables from outer scopes, which can be intentional or
accidental. Loop variables often cause problems when closures are created in
loops. The classic mistake is
for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100) } which
logs 3 three times because all closures share the same i. Using let or
proper closure patterns fixes this.
typeof checks prevent operations on wrong types. Before calling string
methods, checking typeof value === 'string' prevents runtime errors. This
defensive programming catches issues early. When we see operations on values
that might be wrong types, typeof checks improve reliability.
null and undefined both represent absence but have different meanings.
undefined means a value hasn't been assigned, while null represents
intentional absence. Functions return undefined by default, while null typically
indicates a deliberate choice to return nothing. Code that confuses these or
treats them identically might have subtle bugs.
Function Design and Scope
Pure functions produce the same output for the same input without side effects. This makes functions predictable and testable. When reviewing functions, we verify they don't modify parameters, global state, or external resources unless that's their explicit purpose. Pure functions enable reasoning about code locally without tracking global state.
Function parameters should be limited—more than three suggests the function does
too much or parameters should be grouped into an object. The pattern
function createUser(name, email, age, role, department) becomes unwieldy.
function createUser({ name, email, age, role, department }) uses an object
parameter that's easier to call and extend.
Single responsibility means functions do one thing well. Long functions typically do multiple things and should be decomposed. When reviewing functions longer than a screen, we consider whether they should be split. Clear function names and small, focused implementations make code self-documenting.
Early returns reduce nesting and improve readability. Instead of
if (condition) { // long code } else { return }, we write
if (!condition) return; // long code. This eliminates else blocks and reduces
indentation. When we see deep nesting, early returns often flatten the
structure.
Modifying parameters creates hidden side effects that make functions harder to understand and test. When a function receives an object and modifies it, callers might not expect this. Returning new values instead of mutating parameters makes side effects explicit and preserves input data.
Array and Object Operations
Array methods like map(), filter(), and reduce() express transformations
functionally. Instead of manual loops building new arrays, these methods
communicate intent clearly. array.filter(x => x > 10).map(x => x * 2) reads as
"filter then transform," making the operation's purpose obvious.
Mutation creates bugs when multiple parts of code reference the same object or array. Immutable operations that return new values prevent these bugs. When we see code pushing to arrays or modifying object properties, we consider whether returning new values would be safer. The slight performance cost of creating new objects rarely matters compared to the reliability benefit.
Optional chaining ?. safely accesses nested properties. The expression
user?.profile?.email returns undefined if any step is null or undefined,
preventing TypeErrors. This should be our default for accessing properties that
might not exist. When we see manual existence checks before each property
access, optional chaining simplifies the code.
Nullish coalescing ?? provides defaults for null or undefined. Unlike ||
which treats any falsy value as absent, ?? only replaces null and undefined.
count ?? 0 preserves 0 as a valid count while providing a default for absent
values. When we see || default for values where 0 or empty string are valid,
?? expresses intent correctly.
Object spread creates shallow copies efficiently.
{ ...original, modified: true } creates a new object with original's
properties plus the modification. This immutable pattern makes changes explicit
and prevents modifying shared objects. When we see manual property copying,
spread syntax improves clarity.
Module Organization
ES6 modules provide standard import/export syntax across JavaScript
environments. import { function } from './module' and
export function function() {} create explicit dependencies that build tools
can analyze. When we see CommonJS require() in code targeting modern
environments, ES6 modules provide better tooling support.
Named exports work well for modules exposing multiple values. Default exports suit modules with a single primary export. Mixing both from the same module confuses consumers about how to import. When reviewing modules, consistent export style makes the API predictable.
Import statements at file tops make dependencies clear. Scattered imports or conditional imports complicate understanding what the module needs. Bundlers also expect top-level imports for optimization. When we see imports inside functions, we verify the dynamic import is necessary.
Circular dependencies create initialization problems and suggest design issues. When module A imports B and B imports A, the cycle prevents clean initialization. When we encounter import errors or undefined values from imports, circular dependencies are common culprits. Restructuring to extract shared code into a third module resolves the cycle.
Dynamic imports enable code splitting for better load performance. The pattern
import('./module').then(module => module.function()) loads code on demand
rather than upfront. This proves valuable for large applications where initial
load time matters. When modules are only needed conditionally, dynamic imports
reduce bundle size.
Bringing It Together
Effective JavaScript code review requires understanding both the language's quirks and modern best practices. JavaScript's flexibility creates numerous ways to accomplish the same goal, some better than others. Code review helps teams converge on patterns that work reliably and read clearly.
Not every issue demands fixing before merge. A missing const that could be let might not matter. Missing error handling on a promise that could crash production absolutely matters. The key is distinguishing issues that affect reliability from those that represent style preferences or minor polish.
Modern JavaScript continues evolving with yearly ECMAScript releases. Features like optional chaining, nullish coalescing, and top-level await improve the language significantly. Teams benefit from adopting new features that solve real problems, and code review creates opportunities to share knowledge about these capabilities and discuss when to use them effectively.

