Prepare for JavaScript interview questions grouped by experience level.
0-2 Years
JavaScript is a scripting language originally built to add interactivity to web pages, running directly inside a browser. It's since expanded well beyond that, running on servers through Node.js, in mobile apps, and in desktop applications, but the browser is still where most people first encounter it.
var is function-scoped and can be redeclared, an older way of declaring variables that predates the other two. let is block-scoped and can be reassigned but not redeclared in the same scope. const is also block-scoped but can't be reassigned at all once it's set, though an object or array declared with const can still have its own contents modified.
string, number, boolean, undefined, null, symbol, and bigint. Everything else, arrays, functions, plain objects, is technically an object under the hood, which behaves quite differently from a primitive when it comes to comparison and copying.
undefined means a variable has been declared but hasn't been assigned a value yet. null is an intentional, explicit assignment representing the deliberate absence of a value. A variable becomes undefined automatically if you never set it, while it only becomes null if you or your code specifically set it that way.
== compares two values for equality after converting them to a common type if they're different types, sometimes called loose equality. === compares both value and type without any conversion, called strict equality. '5' == 5 is true, but '5' === 5 is false, since one is a string and the other a number.
Type coercion is JavaScript automatically converting a value from one type to another when an operation expects a different type, like converting a number to a string when it's concatenated with one using +. It can produce genuinely surprising results, which is exactly why === is generally preferred over == in most real code, to avoid relying on that automatic, sometimes unpredictable conversion.
function greet() {} is a function declaration. const greet = function() {} is a function expression, assigned to a variable. const greet = () => {} is an arrow function, a more compact syntax introduced in ES6. All three ultimately define a reusable, callable block of code.
A function declaration is hoisted entirely, meaning it can be called before it appears in the code, since the whole function definition is processed before any code actually runs. A function expression is only hoisted as a variable, not with its actual function body attached, so it can't be called until the line where it's actually assigned has been executed.
A parameter is the named placeholder listed in a function's definition, like name in function greet(name). An argument is the actual value passed in when the function is called, like greet('Anu'), where 'Anu' becomes the value bound to the name parameter for that specific call.
function greet(name = 'Guest') {} gives name a fallback value of 'Guest' used automatically whenever the caller doesn't provide an argument for it, or explicitly passes undefined. It avoids needing a manual check inside the function body for a missing argument.
An arrow function has a more compact syntax and doesn't have its own this binding, instead inheriting this from the surrounding scope where it was actually defined. A regular function does have its own this, which gets determined dynamically based on how that particular function is actually called, not where it was written.
return sends a value back to wherever the function was called and immediately stops the function's execution at that exact point. A function with no return statement at all implicitly returns undefined once it reaches its end, even though nothing was written to say so explicitly.
const person = { name: 'Anu', age: 25 }; creates an object using object literal syntax, the most common way. Properties are accessed with dot notation, person.name, or bracket notation, person['name'], with bracket notation needed specifically when the property name is stored in a variable or contains a character dot notation can't handle.
Dot notation, obj.property, is simpler and more common, but requires a valid, fixed identifier as the property name known in advance. Bracket notation, obj['property'], accepts a string (or a variable holding a string), which lets you access a property dynamically, obj[variableName], when the specific property name isn't known until the code actually runs.
const fruits = ['apple', 'banana', 'orange']; creates an array. Elements are accessed by their zero-based index, fruits[0] returns 'apple', the very first element.
push() adds an element to the end. pop() removes and returns the last element. shift() removes and returns the first element. unshift() adds an element to the beginning. slice() returns a new, shallow copy of a portion of the array without modifying the original.
slice() returns a new array containing a copy of a specified portion, leaving the original array completely untouched. splice() modifies the original array directly, removing or replacing elements in place, and can also insert new elements at a specified position.
Array.isArray(variable) reliably checks whether a value is genuinely an array. Using typeof for this doesn't work correctly, since typeof an array returns 'object', the exact same result it returns for a plain object, which doesn't distinguish between the two at all.
The DOM (Document Object Model) is the browser's in-memory, tree-shaped representation of an HTML page's actual structure. JavaScript interacts with it by reading and modifying that tree directly, which the browser then reflects visually, letting a script dynamically change what's actually shown on the page after it first loads.
document.getElementById('id') selects one specific element by its unique ID. document.querySelector('.class') selects the first element matching a given CSS selector. document.querySelectorAll('.class') selects every matching element, returned together as a NodeList.
element.addEventListener('click', function() { ... }); attaches a handler function that runs whenever the specified event, click in this case, occurs on that element. Multiple listeners can be added to the exact same event on the exact same element, and each one runs independently.
innerHTML gets or sets an element's content as raw HTML markup, actually parsing any tags included within it. textContent gets or sets content strictly as plain text, treating anything like <b> or <script> as literal, visible characters rather than actual markup, and is the safer choice when inserting content that comes from user input, since it avoids the risk of unintentionally executing injected HTML or script.
When an event fires on a specific element, it doesn't just trigger a handler on that element alone. It also propagates upward, triggering matching handlers on that element's ancestors too, one level at a time, all the way up to the document root, unless something explicitly stops that propagation partway through.
Inside the submit event's handler function, call event.preventDefault(). This stops the browser's default behavior for that specific event, letting you run your own custom logic, like validation, instead of allowing the browser's normal full-page-reload form submission to happen automatically.
if-else evaluates a series of conditions, one after another, running the first block whose condition evaluates to true. A switch statement compares one single value against several possible fixed cases, generally reading more cleanly than a long if-else chain specifically when you're comparing the exact same variable against several different possible discrete values.
A standard for loop gives you full manual control over the counter and the loop's exact condition. for...in iterates over an object's own enumerable property keys. for...of iterates over the actual values of an iterable, like an array or a string, which is generally what you actually want when looping through an array's contents.
A truthy value behaves as true when evaluated in a boolean context, like an if statement's condition. A falsy value behaves as false in that same context. JavaScript's specific falsy values are false, 0, '' (an empty string), null, undefined, and NaN. Every other value, including an empty array or an empty object, is actually considered truthy.
a || b returns a if a is truthy, otherwise it returns b, commonly used to provide a fallback default value. a && b returns a if a is falsy (short-circuiting immediately), otherwise it returns b, commonly used to conditionally run something only when a is genuinely truthy.
?? returns its right-hand value only when the left-hand value is specifically null or undefined, not for any other falsy value. || returns its right-hand value for any falsy left-hand value at all, including 0 or an empty string, which can cause a genuine bug when 0 or '' are actually valid, intentional values that shouldn't be replaced by a fallback.
Template literals, written with backticks, let you embed an expression directly inside a string using ${expression} syntax, like `Hello, ${name}!`. They also support genuine multi-line strings without needing an explicit newline character, both of which are noticeably more readable than building the equivalent string through repeated + concatenation.
Destructuring extracts values from an object or an array into individual, separate variables in one concise step. const { name, age } = person; pulls the name and age properties directly out of the person object. const [first, second] = array; pulls the first two elements out of an array, each into its own named variable.
The spread operator expands an iterable, like an array, into its individual elements, used when calling a function or building a new array or object, like [...array1, ...array2] to combine two arrays into one. The rest parameter does the opposite, collecting multiple individual arguments together into a single array, used specifically inside a function's own parameter list.
Beyond embedding a variable's value directly inside a string, template literals support genuine multi-line strings without any special escape character, and can also be used with tagged templates, a more advanced feature that lets a function process a template literal's individual pieces before the final string is actually produced.
If an arrow function's body is a single expression written without curly braces, like const double = x => x * 2;, that expression's value is automatically returned without needing an explicit return keyword at all. Adding curly braces around the function body switches it back to needing an explicit return statement, just like a regular function.
Modules let you split JavaScript code across multiple separate files, with export marking which specific values or functions a file makes available to other files, and import bringing those specific exported values into whichever file actually needs them. They solve the real problem of one giant, unmanageable global script file, letting code be organized into smaller, independently reusable, more maintainable pieces instead.
3-6 Years
A closure is a function that retains access to variables from its enclosing (outer) scope, even after that outer function has already finished running and returned. It happens naturally whenever a function is defined inside another function and that inner function is then used somewhere outside of where it was originally created.
A common one is creating a private counter: a function returns an inner function that increments and reads a variable defined in the outer function's scope, and that variable stays genuinely private, inaccessible from outside, accessible only through the specific inner function that was actually returned and closes over it.
When JavaScript looks up a variable, it first checks the current, local scope, and if it's not found there, it moves outward to check each enclosing (parent) scope in sequence, continuing all the way up to the global scope. This entire ordered sequence of scopes being checked, one after another, is called the scope chain.
Hoisting is JavaScript's behavior of moving variable and function declarations to the top of their containing scope during the compilation phase, before the code actually starts executing line by line. var declarations are hoisted and initialized to undefined immediately, while let and const are hoisted too but stay in a temporal dead zone, genuinely inaccessible, until the actual line where they're declared is reached during execution.
Global scope is accessible from anywhere in the entire codebase. Function scope is accessible only within the specific function it was actually declared inside, applying to var. Block scope is accessible only within the specific block, like an if statement or a loop, it was declared inside, applying to let and const, but var ignores block boundaries entirely and doesn't respect this scope at all.
Synchronous code executes line by line, and each line fully completes before the next one even begins. Asynchronous code lets a slow operation, like a network request, run in the background without blocking the rest of the code from continuing to execute in the meantime, resuming once that background operation actually finishes.
A callback is a function passed as an argument, meant to run once some operation completes. Callback hell happens when callbacks get nested inside other callbacks several levels deep, each one waiting on the previous, forming a sideways, hard-to-read staircase of indentation that's genuinely painful to follow or maintain.
A Promise represents the eventual outcome of an asynchronous operation, existing in one of three states: pending, fulfilled, or rejected. Instead of passing a callback directly into a function, the function returns a Promise, and you attach .then() to handle success and .catch() to handle failure, avoiding callback hell's deep, awkward nesting.
async/await is syntax built directly on top of Promises. Marking a function async lets you use await inside it to pause its execution until a Promise resolves, without writing an explicit .then() chain. Under the hood it's still Promises doing the actual work, but the code reads more like ordinary, top-to-bottom synchronous logic.
Wrap the awaited call in a try block, and catch any error, whether it's a rejected Promise or a thrown exception, inside the corresponding catch block. try { const data = await fetchData(); } catch (error) { console.log(error); } handles a failed fetch gracefully instead of letting an unhandled rejection crash or silently fail elsewhere.
Every JavaScript object has an internal link to another object, its prototype, and when a property or method isn't found directly on the object itself, JavaScript automatically looks it up on that prototype instead, and continues up the chain of prototypes until it either finds it or reaches the very end, null.
A class, introduced in ES6, provides cleaner, more familiar syntax for defining a constructor and its associated methods together in one place. A constructor function achieves the exact same practical result using an older, more manual syntax, explicitly attaching methods to the function's own prototype object by hand. Both ultimately rely on the exact same underlying prototype-based inheritance mechanism.
this refers to whatever object the function was actually called on, determined dynamically at the moment of the call itself, not based on where the function happens to be defined in the code. Calling obj.method() sets this to obj inside that method, while calling that exact same function on its own, standalone, sets this to undefined in strict mode, or the global object otherwise.
An arrow function doesn't have its own this binding at all. Instead, it inherits this from the surrounding, enclosing scope where it was actually defined, which is exactly why arrow functions are so commonly used inside a callback where you specifically want to preserve the outer this, like inside a class method's own internal callback.
The extends keyword lets one class inherit properties and methods from another. class Dog extends Animal {} lets Dog automatically gain everything Animal defines, while still being free to add its own additional methods or override an inherited one to behave differently for that specific subclass.
map() transforms every single element and returns a brand new array of the exact same length, containing the transformed results. filter() returns a new array containing only the elements that satisfy a given condition, which can be shorter than the original. reduce() combines every element down into a single accumulated value, like a running total.
array.reduce((sum, current) => sum + current, 0); starts an accumulator at 0 and adds each element to it in turn, ultimately returning the final total once every element has been processed.
forEach() runs a given function once for each element but doesn't return anything at all, useful purely for a side effect like logging. map() also runs a function for each element, but it collects the actual returned values into and returns a brand new array, useful specifically when you need the transformed results themselves rather than just the side effect of running something.
Object.keys() returns an array of an object's own property names. Object.values() returns an array of its corresponding values. Object.entries() returns an array of [key, value] pairs together, useful for looping through both a key and its matching value at the exact same time, like with a for...of loop.
The event loop is the mechanism that lets JavaScript, despite running on a single main thread, handle asynchronous operations without ever actually blocking that thread. It continuously checks whether the call stack is empty, and if it is, pulls the next queued callback (from finished asynchronous work) and pushes it onto the stack to actually run.
The microtask queue holds Promise callbacks and a few similar tasks, and it's always fully drained, every single microtask processed, before the event loop moves on to the next macrotask. The macrotask queue holds things like setTimeout callbacks and DOM events, and only one macrotask runs per full cycle of the event loop, unlike microtasks, which all run together in one batch.
The Promise callback goes into the microtask queue, which the event loop always fully empties before it even looks at the macrotask queue where setTimeout callbacks live. So no matter how small the timeout delay is set to, even zero, a microtask queued around that same moment will always run first.
The call stack tracks currently active function calls, adding a new frame each time a function is called and removing it once that function actually returns. That error occurs when the stack grows too deep, most commonly caused by a recursive function that's missing its base case, or one whose base case is genuinely never actually reached, so the stack keeps growing until it runs out of available space.
6-8 Years
Promise.all() takes an array of Promises and resolves once every single one of them has resolved, returning an array of all their results together, in the exact same order they were originally passed in. If even one Promise in that array rejects, Promise.all() immediately rejects too, with that same specific error, regardless of whether the other Promises eventually would have succeeded.
Promise.all() rejects immediately the moment any single Promise in the array rejects, discarding the results of the others entirely, even the ones that did actually succeed. Promise.allSettled() instead waits for every single Promise to finish, whether it succeeded or failed, and returns a full array of results describing each individual Promise's actual outcome, success or failure, letting you handle partial, mixed success gracefully.
Promise.race() resolves or rejects as soon as the very first Promise in a given array settles, whether that first one to finish succeeds or fails, ignoring the rest entirely once that first one resolves. A practical use is implementing a timeout, racing an actual data-fetch Promise against a separate Promise that simply rejects after a fixed delay, whichever happens to finish first.
Start all of the relevant async operations first without immediately awaiting each one individually, collect the resulting Promises into an array, then await Promise.all() on that whole array together. Awaiting each one sequentially with a separate await statement runs them one after another instead, needlessly losing the actual performance benefit of running them concurrently.
An async iterator lets you iterate over a sequence of values where each individual value itself needs to be awaited, using a for await...of loop. It's useful for something like processing paginated API results one page at a time, where fetching each subsequent page is itself an asynchronous operation that needs to be awaited before moving on to the next one.
Add logging with timestamps around each asynchronous operation to actually see the real order things are completing in, rather than the order you originally assumed they'd complete in. A race condition often traces back to code that incorrectly assumes one particular async operation will reliably finish before another, when in genuine reality that specific ordering was never actually guaranteed by anything in the code.
AbortController lets you cancel an in-progress asynchronous operation, most commonly a fetch request, by calling abort() on its associated signal. It solves the real problem of a component unmounting or a user navigating away while a request is still pending, letting the code proactively cancel that now-irrelevant request rather than letting it complete uselessly in the background and potentially try to update state that no longer even exists.
Even with automatic garbage collection, an object can't be reclaimed as long as something still holds a reference to it. A memory leak in JavaScript usually means something, an event listener that was never removed, a growing array or cache with no limit, a closure unintentionally holding onto a large object longer than intended, is preventing garbage collection from ever actually reclaiming that memory.
Debouncing delays running a function until a specified amount of time has genuinely passed since the last time it was actually called, resetting that timer on every new call in the meantime. It's commonly used on a search input's keystroke handler, so an actual API call only fires once the user has genuinely paused typing, rather than firing on every single individual keystroke.
Throttling ensures a function runs at most once within a specified time interval, regardless of how many times it's actually called during that same window. Debouncing waits for a genuine pause in calls before running at all. Throttling fits something like a scroll event handler, where you genuinely want regular, periodic updates, while debouncing fits waiting for input to genuinely settle before actually reacting to it.
The browser's DevTools Memory tab lets you take heap snapshots at different points in time and compare them directly, showing exactly which objects are actually growing in count and, importantly, what's still holding a reference to them and thereby preventing garbage collection. Without taking an actual snapshot, memory leaks are genuinely difficult to diagnose just by reading code alone.
Object pooling reuses a fixed set of pre-created objects instead of constantly creating and then immediately discarding new ones, reducing pressure on the garbage collector. It's worth the real added complexity specifically in a genuinely performance-critical scenario, like a game creating and destroying many short-lived objects every single frame, where the actual overhead of constant garbage collection would otherwise become a real, measurable bottleneck.
8-10 Years
The module pattern uses a function's own closure to create genuinely private state and expose only a specific, deliberate public interface, avoiding polluting the global scope with variables that should have stayed private. Native ES6 modules have largely replaced the need for this specific pattern in most modern code, but the same underlying idea of encapsulating private state still applies broadly.
Singleton ensures a class or object has exactly one single instance shared everywhere it's actually used. In JavaScript, this is often achieved simply by exporting a single, already-created instance directly from a module, since a module's own exports are naturally cached and shared across every single place that happens to import it.
The Observer pattern lets a subject notify a list of subscribed observers automatically whenever its state changes, without the subject needing to know any specific details about those particular observers. JavaScript's native addEventListener mechanism is essentially a built-in implementation of exactly this pattern, where the DOM element is the subject and each registered listener is one of its observers.
Functional programming emphasizes pure functions, avoiding shared mutable state, and treating a function itself as a genuine first-class value that can be passed around like any other data. JavaScript supports this well since functions genuinely are first-class values, and array methods like map, filter, and reduce all directly encourage a functional style of thinking, even though JavaScript itself still fully allows mutation and side effects wherever you choose to use them.
Currying transforms a function that takes multiple arguments into a sequence of functions that each take just one single argument at a time, returning a new function at each individual step until all the arguments have finally been supplied. It's useful for creating specialized, pre-configured versions of a more general function, like a generic multiply(a, b) curried into a specific double = multiply(2), pre-filling just the first argument once and reusing that specialized version repeatedly.
Memoization caches a function's result keyed by its specific input, so calling that same function again with the exact same input returns the cached result instantly rather than recomputing it entirely from scratch. A simple implementation wraps the original function, checking a Map for the input's cached value first before actually running the original, expensive computation, and storing that computed value in the Map once it's actually calculated for the first time.
A bundler, like webpack or Vite, combines many separate source files and their dependencies into a smaller number of optimized output files, ready for a browser to actually load efficiently. It's needed because loading dozens or hundreds of individual, separate files directly in a browser would be genuinely slow, and a bundler also handles transforming genuinely newer JavaScript syntax into a form that older, less capable browsers can actually understand and run.
A generator function, declared with function*, can pause its own execution at a yield statement and later resume exactly from that same point, rather than running straight through to completion in a single, uninterrupted pass the way a regular function does. Calling a generator function doesn't run its code immediately, it instead returns an iterator you then use to actually step through the function's execution one yield at a time.
A Proxy wraps an object and lets you intercept and customize fundamental operations on it, like reading a property, setting a property, or checking whether a property genuinely exists at all. A practical use case is validation, intercepting a property assignment specifically to check that the new value being set actually meets some defined rule before genuinely allowing that assignment to actually go through.
A Symbol is a genuinely unique, immutable primitive value, often used as an object property key specifically to avoid any accidental naming collision with another, completely unrelated property that happens to share the exact same string name. It was introduced to let library code safely add its own properties to an object without any real risk of quietly colliding with a property the object's own actual owner might have separately, independently defined.
A WeakMap only accepts objects as keys, and it holds those keys weakly, meaning it doesn't itself prevent an unused key object from ultimately being garbage collected once nothing else in the code still references it. A regular Map holds strong references to every one of its keys, which means an object used as a Map key will never actually be garbage collected as long as that Map itself still exists and holds a reference to it.
A Set automatically enforces uniqueness, silently ignoring an attempt to add a value that's already present, and offers faster membership checks (has()) than an array's linear search does. An array allows duplicates and requires manually filtering them out if uniqueness is actually needed, which is exactly why a Set is the more natural, purpose-built choice whenever a collection genuinely needs to guarantee no duplicate values.
Optional chaining, obj?.property?.nestedProperty, safely accesses a deeply nested property, immediately returning undefined the moment any link along that particular chain turns out to be null or undefined, rather than throwing an actual runtime error partway through. Without it, safely accessing the exact same deeply nested value would otherwise require writing several separate, explicit null checks chained together manually, one for each individual level.
A tagged template literal passes a template literal's individual string pieces and interpolated values separately into a specified function, which can then process them however it likes before actually producing the final resulting string. A practical use case is a styled-components-style CSS-in-JS library, or safely escaping user-provided values automatically before they're actually inserted into a larger constructed string.
A shallow copy, created with Object.assign({}, obj) or the spread operator {...obj}, duplicates only the top-level properties, so a nested object inside is still shared by reference between the original and the copy. A deep copy duplicates every nested level too, commonly done with structuredClone(obj) in modern JavaScript, producing two genuinely independent objects with no shared underlying data at all.
10+ Years
I'd weigh the actual, concrete pain the team is currently experiencing, runtime bugs that a static type system would have genuinely caught earlier, difficulty confidently refactoring a large codebase without types, against the real cost of both the initial migration itself and the ongoing learning curve for anyone on the team not yet genuinely comfortable with TypeScript. For a small, short-lived script, the added overhead usually isn't remotely worth it. For a large, long-lived application maintained by many different people over time, it very often genuinely is.
Run it incrementally wherever that's genuinely possible: get the codebase working correctly under both the old and new tooling in parallel for a defined transition period, lean on existing automated test coverage to catch any regressions early, and migrate section by section rather than attempting one single, large, disruptive rewrite all at once. A full stop-everything migration is rarely something the business will actually tolerate for very long.
I look at whether it's genuinely solving the real, underlying problem or just addressing a surface-level symptom of it, whether state management and asynchronous logic are handled in a way that will hold up reasonably well as the application continues to grow, and whether it's genuinely consistent with patterns already established elsewhere in the codebase. An inconsistent one-off pattern becomes a real maintenance burden the whole team inherits later on.
Automate what can genuinely be automated, linting, formatting, and static type checking if TypeScript is actually in use, enforced directly in CI so standards aren't purely a matter of individual opinion during manual code review. For the architectural conventions that genuinely resist full automation, I'd document the handful of decisions that actually matter most, along with the real reasoning behind each one, rather than a long style guide nobody actually reads end to end.
Check actual, current browser or Node.js version support against the organization's genuinely real user base or deployment targets, rather than assuming universal support based on a general impression. I'd also weigh the concrete, measurable benefit the new feature actually provides against the real cost of a team having to learn it and any added tooling or transpilation genuinely required to support it consistently everywhere it's actually needed.
First check whether it correlates with device or browser differences, since a heavy computation or a large, unoptimized DOM update that runs fine on a powerful desktop can genuinely block the main thread and freeze the page on a meaningfully weaker mobile device. Real user monitoring, capturing actual performance data from genuinely real users rather than relying only on your own development machine, often reveals a very different picture than a controlled local test alone ever would.
A global error handler, using window.onerror or an unhandledrejection listener for Promise-based errors, at minimum logs the actual error for later visibility rather than letting it fail completely silently. For a component-based UI framework, error boundaries around genuinely independent sections of the page mean a failure in one specific section doesn't necessarily take down other, unrelated parts of the page that have nothing to do with it.
Treat the module's actual exported public functions as a genuine contract with every team that consumes it. Adding a new function is generally safe. Changing or removing an existing function's actual behavior or signature needs a documented deprecation period and direct, proactive communication with consuming teams well before removal, rather than a silent breaking change that quietly surfaces later as someone else's completely unrelated-seeming production bug.
I'd check first for an external dependency change, a third-party script or API the page genuinely depends on that may have changed or gone down entirely, since that's a very common cause of exactly this kind of symptom with no corresponding code deploy of your own to point to. Browser console errors and error-monitoring tool reports from genuinely affected users usually point fairly directly at the actual specific failing piece once you actually look.
Set and actually enforce a real performance budget, a defined maximum acceptable bundle size and load time, checked automatically in CI, rather than letting it silently and gradually creep up feature by feature with nobody deliberately deciding that trade-off along the way. Regular bundle analysis catches an unexpectedly large new dependency before it ever actually ships to every single user, not sometime well after the fact.
This is a judgment question interviewers use to see how you reason under genuine uncertainty, not to test a specific textbook fact. A strong answer names the actual constraint that forced the decision, the realistic options that were genuinely on the table, why you picked one knowing it wasn't guaranteed to be right, and what you'd do differently with what you know now.
I'd walk through one of their actual bugs together, tracing exactly how that shared global state ended up getting unexpectedly modified from somewhere they genuinely didn't anticipate, rather than lecturing about scope and encapsulation purely in the abstract. Seeing a real, concrete bug trace directly back to that specific habit tends to shift their instincts far more effectively than a general rule about avoiding globals ever does on its own.
I wouldn't push a full, disruptive rewrite. I'd migrate one genuinely painful, deeply callback-nested file as a visible, concrete example, letting the team directly see the readability difference on code they already recognize, and let that build organic buy-in rather than mandating the change purely from the top down before anyone's actually seen the real benefit for themselves.
I'd focus the discussion on the actual, specific situation at hand rather than a blanket, one-size-fits-all rule for every case. async/await usually reads more clearly for a genuinely sequential series of steps, while raw Promise chaining, or explicit Promise.all, can actually be clearer for something like fully parallel operations. Grounding the discussion in the specific code in front of us resolves it faster than debating a general, abstract style preference.
I'd translate the debt into terms leadership already tracks: a specific incident or delayed feature that traced directly back to it, and how much longer a typical change in that specific area now takes compared to a genuinely well-structured part of the same codebase. Framed as a velocity problem with a real, already-incurred cost behind it, it competes far better for prioritization than framed as a general code-quality concern.




