JavaScript interviews for 5 years of experience stop asking how a feature works and start asking why you chose something and what it cost. Expect questions on deploys that broke open tabs, data bugs, async design, browser performance, security calls, and stories about reviews and mentoring. It is written for JavaScript developers with roughly five to seven years behind them who own a module or a part of the front end, make design calls inside it, get pulled in when it breaks and review other people's code. Each answer is a first-person story or a decision you can defend. Swap in your own project details before you say it out loud.
Search all questions by round, difficulty and level, or save the ones you want to practice.
Cause: an open tab still runs the old bundle, which asks for old chunk files the deploy deleted.
Deploy fix: keep the previous builds' files for a while, and never cache the HTML page for long.
Safety net: catch a failed dynamic import and reload once, with a guard so it can't loop.
“Our build gave every chunk a hashed file name, and each deploy replaced the whole assets folder. Anyone who had the app open was still running the old main bundle, so when they clicked into a lazy-loaded page, it asked for an old chunk that no longer existed and got a 404. The error spikes lined up exactly with deploy times, which is how I found it. The real fix was in the deploy: we kept the previous two builds' files on the server, so old tabs could still load what they asked for. We also made sure the HTML page was never cached for long, while hashed files were cached for a year. As a safety net, a failed dynamic import now triggers one full reload, with a flag so it can't loop. The cost was a bit more storage on the server.”
async function loadPage(importer) {
try {
const mod = await importer();
sessionStorage.removeItem('chunk-reload');
return mod;
} catch (err) {
if (!sessionStorage.getItem('chunk-reload')) {
sessionStorage.setItem('chunk-reload', '1');
location.reload(); // pick up the new build, once
}
throw err;
}
}
const reports = await loadPage(() => import('./reports.js'));
Telling users to clear their cache, or retrying the same import, which asks for the same missing file again.
Cause: the new worker installs, then waits until no tab is using the old one.
Update flow: spot the waiting worker, tell the user, and activate it when they agree.
Hygiene: a cache name per release, old caches deleted on activate.
“We cached the app shell with a service worker so the app opened offline. After one release, support heard from people still on the old version days later. The browser had found and installed the new worker, but a new worker waits until no tab is using the old one. These users never closed their tab; they just refreshed it, and a refresh doesn't count, because the old worker keeps control. I didn't want to call skipWaiting on every install, because swapping workers under a running page can mix old code with newly cached files. So the page now checks for a waiting worker and shows a small 'New version, refresh' banner. When the user clicks it, we tell the worker to skip waiting and reload once it takes control. Each release also gets its own cache name. The cost is that some people stay on the old version until they click.”
// page
let refreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!refreshing) { refreshing = true; location.reload(); }
});
navigator.serviceWorker.register('/sw.js').then((reg) => {
const offer = (w) => showUpdateBanner(() => w.postMessage('SKIP_WAITING'));
if (reg.waiting) offer(reg.waiting);
reg.addEventListener('updatefound', () => {
const w = reg.installing;
w.addEventListener('statechange', () => {
if (w.state === 'installed' && navigator.serviceWorker.controller) offer(w);
});
});
});
// sw.js
self.addEventListener('message', (event) => {
if (event.data === 'SKIP_WAITING') self.skipWaiting();
});
Telling users to clear site data, or not knowing that a new worker waits while old tabs are still open.
Data: which browsers your real users and key customers use, not global charts.
Measure: change the build's browser targets and compare what you actually save.
Safety: a plain 'please update' message, errors watched by browser, a quick way back.
“I'd start with our own analytics, not general usage charts, and look at who's on the old browsers. If it's a tiny slice but includes a big customer's locked-down office machines, that changes the answer. Then I'd change the build's browser targets and compare bundle sizes, so we know what we'd really save. It's often a lot, because old targets force extra helper code and large polyfills. For rollout, I'd add a tiny separate script, written in old syntax, that shows a 'please update your browser' message. It can't live inside the main bundle, because new syntax in an old browser is a parse error and none of that file runs. After release I'd watch error monitoring split by browser for a week. At my last company the saving was worth it, and we kept a one-line switch to rebuild with the old targets if support tickets jumped.”
Deciding from global browser share or your own laptop, with no plan for the users who will now see a blank page.
Cause: an unbounded Map holding keys and results that are never removed.
Object keys: a WeakMap lets entries go when the key object is no longer used.
Other keys: a size-limited LRU cache, and accept the recompute when an entry is evicted.
“We memoized a function that built formatted views of report configs, using a plain Map keyed by the config object. It made the page fast, but on a dashboard people left open all day, memory crept up until the tab crashed. A heap snapshot showed the Map holding thousands of old configs that nothing else used, because the Map itself kept them alive. For that case I switched to a WeakMap, so an entry disappears once the config object is gone. Another memoized function took string keys, which a WeakMap can't hold, so I wrote a small LRU on top of Map insertion order and capped it. The trade-off is that a WeakMap can't tell you its size, and an LRU sometimes recomputes something it threw away, which we measured as cheap.”
function lruMemo(fn, max = 500) {
const cache = new Map();
return (key) => {
if (cache.has(key)) {
const value = cache.get(key);
cache.delete(key);
cache.set(key, value); // move to most recently used
return value;
}
const value = fn(key);
cache.set(key, value);
if (cache.size > max) cache.delete(cache.keys().next().value); // oldest
return value;
};
}
Adding a cache with no limit and no eviction, and treating memory as free.
Cut noise: drop errors that come only from browser extensions, and group the rest properly.
Make readable: upload source maps, tag errors with the release, fix 'Script error.' at the source.
Alert on change: new error types after a deploy, not raw totals.
“We had thousands of errors a day, mostly from browser extensions injecting scripts, bots and a few harmless browser warnings, so everyone had learned to ignore the dashboard. A broken payment button sat in there for a week. I spent a couple of days cleaning it up. We filtered out errors whose stack pointed only at extension files and grouped the rest properly. A big bucket was just 'Script error.' with no details, which is what the browser reports for errors in scripts from another origin. We added the crossorigin attribute and the matching CORS header on our CDN, and real stack traces appeared. We uploaded source maps to the error tool without serving them publicly, and tagged every error with the release. Then alerts fired on new error types after a deploy. The cost is keeping the filter rules up to date, but people trust the dashboard again.”
Muting the alerts because they're noisy, or working through the error list by count with no idea which ones hurt users.
Cause: 64-bit IDs parsed as numbers lose digits above Number.MAX_SAFE_INTEGER.
Fix: send IDs as strings and treat them as opaque, never as numbers.
Why not BigInt: JSON.parse still makes a rounded number, and JSON.stringify throws on BigInt.
“The backend had moved to 64-bit IDs, and once they grew past about nine quadrillion, JSON.parse turned them into JavaScript numbers that couldn't hold every digit. Two different IDs could round to the same number, so we fetched the wrong record. I proved it in the console by parsing the raw response and comparing it with the text. I looked at BigInt, but a normal JSON.parse reviver only sees the number after it's already been rounded, and JSON.stringify throws on BigInt values, so it would have meant custom parsing everywhere. Instead I agreed with the backend team to send IDs as strings, kept the old numeric field for one release so older clients didn't break, and added a review rule that IDs are never used in arithmetic. It cost a coordinated release, but it removed the whole problem.”
JSON.parse('{"id": 9007199254740993}').id; // 9007199254740992, one off
Number.isSafeInteger(9007199254740993); // false
JSON.parse('{"id": "9007199254740993"}').id; // exact, as a string
Calling it a random backend bug, or trying to fix it by rounding or with parseInt on the client.
Tell other tabs: a BroadcastChannel message, or the storage event, when data changes.
Stop lost writes: send a version with every save and let the server reject a stale one.
Conflicts: show the user what changed and let them choose, rather than silently merging.
“Each tab loaded the record once and kept its own copy, so a save in one tab quietly replaced what the other had saved a minute earlier. I treated it as two problems. To keep tabs informed, after a successful save we post a message on a BroadcastChannel, and any other tab showing that record refetches it or shows a 'changed in another tab' note. That alone isn't safe, though: a tab can miss the message, and the same user might be on a second device. So the real fix was the save itself. Every record has a version, the client sends it back in an If-Match header, and the server answers 412 if someone saved first. The client then shows both versions and lets the user pick. The cost was a version field in the API and a conflict screen, but lost edits stopped.”
const channel = new BroadcastChannel('records');
async function save(record) {
const res = await fetch(`/api/records/${record.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'If-Match': record.etag },
body: JSON.stringify(record.data),
});
if (res.status === 412) return showConflict(record.id); // someone saved first
if (!res.ok) throw new Error(`Save failed: ${res.status}`);
channel.postMessage({ type: 'saved', id: record.id });
}
channel.onmessage = ({ data }) => {
if (data.type === 'saved' && data.id === currentId) refetchRecord(data.id);
};
Fixing only the tab sync and assuming the last save should always win.
Cause: setInterval started a new request on schedule whether the last one had finished or not.
Fix: schedule the next poll only after the current one ends, and back off on errors.
Background: stop while the tab is hidden, fetch once when it's visible again.
“The dashboard used setInterval to fetch every five seconds. That's fine while responses take a few hundred milliseconds, but when the backend slowed down, each request took longer than five seconds, so they piled up on top of each other and made the backend slower still. Hidden tabs kept polling too, and when laptops woke up, lots of dashboards fired at the same moment. I replaced it with a loop that waits for the request to finish and only then schedules the next one with setTimeout, so each tab has at most one request in flight. On errors the delay doubles up to a minute. When the page is hidden we stop, and when it becomes visible we fetch once and carry on. The cost was slightly staler data in background tabs, which nobody was looking at anyway.”
let delay = 5000;
let timer = null;
let running = false;
async function poll() {
if (running) return;
running = true;
clearTimeout(timer);
try {
render(await getJson('/api/dashboard'));
delay = 5000;
} catch {
delay = Math.min(delay * 2, 60000); // back off while the API struggles
} finally {
running = false;
}
if (!document.hidden) timer = setTimeout(poll, delay);
}
document.addEventListener('visibilitychange', () => {
clearTimeout(timer);
if (!document.hidden) poll();
});
poll();
Just making the interval longer, which still stacks requests up whenever the server is slow.
Cache the promise: store it on the first call, so callers who arrive mid-flight share it.
On failure: clear it, so the next caller tries again instead of getting the old error for ever.
Trade-off: callers already waiting all get the same rejection.
“The trick is to cache the promise, not the result. If I only stored the result, callers arriving before the first call finished would each start their own, which is exactly how we once loaded an SDK three times. So the first call starts the work and stores the promise straight away, and every later caller gets that same promise, whether it's still running or already done. The other half is failure. If I kept a rejected promise, the page would be stuck with that error even after the network came back. So when it rejects, I clear the stored promise, and the next caller starts a fresh attempt. Callers already waiting on the failed one still get the error, which I think is right, because each of them can decide whether to retry.”
function onceAsync(fn) {
let pending = null;
return (...args) => {
if (!pending) {
pending = Promise.resolve()
.then(() => fn(...args))
.catch((err) => {
pending = null; // let the next caller try again
throw err;
});
}
return pending;
};
}
const initSdk = onceAsync(() => loadScript('/sdk.js').then(() => window.Sdk.init()));
await Promise.all([initSdk(), initSdk()]); // the script loads once
Using a done flag instead of a stored promise, so callers who arrive during the first call either start it again or get nothing back.
Why: callers loop over items with for await and never see cursors or pages.
Early exit: breaking out stops further fetches and runs any finally block.
Catch: it's sequential by design, so it's the wrong tool when pages could load in parallel.
“We had cursor-based pagination repeated in several places, each with its own loop and bugs around the last page. I wrapped it in one async generator that fetches a page, yields its items, and follows the cursor until there isn't one. Callers just write for await over orders. The nice part was laziness: a caller looking for the first matching order can break, and no more pages are fetched. What caught me out was cleanup. Breaking out calls the generator's return, so a finally block runs, which is where I released a lock, and a teammate's version without finally leaked it. The other trade-off is that it's strictly one page at a time. For a bulk export where speed mattered, I used a concurrency-limited fetch instead.”
async function* allOrders(fetchPage) {
let cursor = null;
do {
const page = await fetchPage(cursor);
yield* page.items;
cursor = page.nextCursor;
} while (cursor);
}
for await (const order of allOrders(getOrdersPage)) {
if (order.flagged) { review(order); break; } // stops fetching more pages
}
Collecting every page into one big array first, then looping, which defeats the point and can blow up memory.
Surface: few exports, an options object instead of long positional arguments.
Predictable: a function is always async or always sync, never sometimes each.
Change: semantic versions, deprecation warnings first, an exports map to hide internals.
“When I built our shared form-validation package, I kept the public surface small and gave functions an options object, so adding a setting later didn't break anyone's argument order. Anything that might ever need the network returned a promise every time, even when a result was cached, because code that's sometimes sync and sometimes async causes ordering bugs that are painful to trace. I listed entry points in the package exports map, so other teams couldn't import our internal files. That choice cost us: two teams already imported internals, so adding the map was a major version, and I helped them move. After that we followed semantic versions strictly, logged a deprecation warning for at least one minor release before removing anything, and kept a changelog that said what to change, not just what changed.”
// package.json: only these paths can be imported by other teams
{
"name": "@acme/validation",
"exports": {
".": "./dist/index.js",
"./rules": "./dist/rules.js"
}
}
Exporting everything for convenience and renaming functions in a minor release.
localStorage: simple, but synchronous, strings only and small, so big writes block the page.
IndexedDB: asynchronous, stores objects and files, but the raw API is awkward.
Costs: shared computers, clearing on logout, and storage the browser may evict.
“The drafts were long and some had attached images, so localStorage was out. It only stores strings, it's small, and every write blocks the main thread, which we'd feel when saving a big draft every few seconds while someone types. I chose IndexedDB behind a tiny wrapper with get, set and delete, so the rest of the code never touched its event-based API. It stores objects and Blobs directly, and writes don't block the page. Then the less technical costs. On a shared computer the next person could read a draft, so we clear drafts on logout and never keep fields marked sensitive. The browser can also evict storage when space runs low, so the draft is a safety net, not the only copy, and we still save to the server while the session is valid. I also debounced saves so we weren't writing on every keystroke.”
function openDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open('drafts', 1);
req.onupgradeneeded = () => req.result.createObjectStore('drafts');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function saveDraft(formId, draft) {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction('drafts', 'readwrite');
tx.objectStore('drafts').put(draft, formId);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
Putting everything into localStorage as JSON without thinking about size, blocking the page or who else uses that computer.
Problem: host pages' global CSS kept breaking the widget.
Options: an iframe isolates everything but is clunky; Shadow DOM isolates styles and stays in the page.
Costs: fonts and theming need planning, and id links like a label's for don't cross the boundary.
“Our booking widget went onto customers' sites with one script tag, and their global CSS kept breaking it, like a site-wide rule restyling every button. An iframe would have isolated everything, but sizing it to its content and passing events across was clunky, and it felt like a separate page. So I built it as a custom element with a shadow root. Page styles stopped leaking in, apart from inherited things like font and colour, which we actually wanted. The costs showed up later. Custom fonts declared only inside the shadow root didn't load reliably, so we declared them in the host document. Customers wanted to theme it, so we exposed CSS custom properties and a few named parts, which became a public API we had to keep stable. And a label outside couldn't point at an input inside by id. I'd choose it again, but plan theming from day one.”
Claiming Shadow DOM isolates everything, including inherited styles, or naming no cost at all.
Direction: updates only flow server to browser, or both ways?
Options: polling is simplest, SSE suits one-way streams, WebSockets suit two-way.
Costs: reconnects, proxies and load balancers, connection limits, and missed events.
“Updates only flowed from server to browser, a few times a minute per order, so I ruled out WebSockets: two-way connections and their own protocol weren't needed. Polling every few seconds would have worked, but with many open dashboards it was a lot of empty requests. I chose server-sent events. It's plain HTTP, EventSource reconnects by itself, and sending an event id lets the server replay what a client missed after a drop. The costs were real, though. Our proxy buffered responses, so events arrived in clumps until we turned buffering off for that route. Over HTTP/1.1, each open stream uses one of the few connections a browser allows per site, which hurt people with many tabs until we served it over HTTP/2. And we kept a slow poll as a fallback for networks that cut long-lived connections.”
const source = new EventSource('/api/orders/stream');
source.addEventListener('status', (e) => {
const { id, status } = JSON.parse(e.data);
updateRow(id, status);
});
source.onerror = () => showStaleBanner(); // EventSource retries by itself
Choosing WebSockets by default for everything, without mentioning reconnects, proxies or missed events.
Cause: a loop that writes a style and then reads a size forces layout every pass.
Fix: do all the reads first, then all the writes, or schedule writes together.
Better still: let CSS do it when grid or flex can handle the layout.
“We had code that equalised card heights on resize. For each card it read the height of its content and then set the card's style height. Each write invalidated layout, so the next read forced the browser to recalculate layout right there, once per card, and with a few hundred cards that meant dropped frames. The performance panel showed a stack of purple layout blocks with a forced reflow warning pointing at our loop. I split it into two passes: read every height first into an array, then write every style. That turned hundreds of layouts into one. Later I went further and replaced most of it with CSS grid, which equalises rows without any JavaScript. The trade-off of the two-pass version is holding the array, which is trivial next to the layout cost.”
const cards = [...document.querySelectorAll('.card')];
// Before: write, then read, every pass forces a layout
cards.forEach((card) => {
card.style.height = `${card.firstElementChild.offsetHeight}px`;
});
// After: all reads, then all writes
const heights = cards.map((card) => card.firstElementChild.offsetHeight);
cards.forEach((card, i) => { card.style.height = `${heights[i]}px`; });
Blaming the framework or adding debounce without understanding that reads and writes are interleaved.
Measure: field data on interaction delay, then reproduce with CPU throttling in the profiler.
Find: the handler filtered, sorted and re-rendered before the browser could paint anything.
Fix: show the visible change first, yield to the browser, then do the heavy work.
“Our real-user monitoring tracked Interaction to Next Paint, and the filter panel was our worst screen, mostly on mid-range phones. On my laptop it felt instant, so I turned on CPU throttling in the performance panel and recorded a click. The handler did everything in one go: updated the selected chip, filtered and sorted a few thousand rows, rebuilt the table and saved the choice to localStorage. The browser couldn't paint the selected chip until all of that finished. I split it. The handler now updates the chip and shows a small 'updating' hint straight away, yields to the browser with a zero timeout, and only then filters and renders just the visible rows. Saving the preference moved to the end. The trade-off is a brief moment where the chip changed but the table hasn't yet, which users didn't mind, and our field numbers came well under target.”
const yieldToMain = () => new Promise((resolve) => setTimeout(resolve, 0));
filterPanel.addEventListener('change', async (event) => {
markSelected(event.target); // cheap, and visible right away
showUpdatingHint();
await yieldToMain(); // let the browser paint first
const rows = applyFilters(allRows, readFilters());
renderVisibleRows(rows);
savePreferences(readFilters());
});
Testing only on a fast laptop and deciding the field data must be wrong.
Sending: pass the exact target origin, never '*' for anything that matters.
Receiving: check event.origin against an allow list, and event.source against the frame you expect.
Data: treat every message as untrusted input, and confirm anything important on the server.
“We embedded a payment-details widget hosted on a partner's domain, and the two sides exchanged messages about size and status. In review I found two gaps. Our page sent messages with '*' as the target origin, so if that frame had been navigated somewhere else, the other page would have received our data. I changed it to the partner's exact origin. And our listener acted on any message with the right type field, so any window holding a reference to ours, like a page that opened us, could send a fake 'payment complete'. Now the listener ignores anything whose origin isn't on our allow list or whose source isn't that iframe, and it validates the message shape before using it. We also never trust a success message alone; the server confirms the payment. The cost was an allow list we keep per environment.”
const WIDGET_ORIGIN = 'https://widget.partner.example';
const frame = document.querySelector('#payment-widget');
frame.contentWindow.postMessage({ type: 'init', orderId }, WIDGET_ORIGIN);
window.addEventListener('message', (event) => {
if (event.origin !== WIDGET_ORIGIN) return;
if (event.source !== frame.contentWindow) return;
const msg = event.data;
if (msg?.type === 'resize' && Number.isFinite(msg.height)) {
frame.style.height = `${msg.height}px`;
}
});
Sending with '*' as the target origin and trusting any message that has the right type field.
How: JSON.parse keeps __proto__ as a normal key, and a naive merge then writes into Object.prototype.
Fix the helper: skip __proto__, constructor and prototype, and only recurse into own properties.
Fix the design: validate input shape, and use Map or Object.create(null) for dictionaries.
“Our settings endpoint deep-merged the request body into a defaults object. JSON.parse keeps a key called __proto__ as an ordinary own property, so when the merge helper read that key on the target, it actually got Object.prototype and copied the attacker's fields onto it. After that, every plain object in the process had, say, an isAdmin property, and a check like if user.isAdmin passed for everyone. I fixed the helper to skip __proto__, constructor and prototype and to only recurse into properties the target owns. More importantly, the endpoint now validates the body against a schema that allows only known keys, and lookup tables keyed by user input became Map objects. I also added a test that sends the payload and checks a fresh object stays clean. The trade-off was rejecting a few unknown keys old clients sent.”
function safeMerge(target, source) {
for (const key of Object.keys(source)) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
const val = source[key];
if (val && typeof val === 'object' && !Array.isArray(val)) {
const cur = Object.hasOwn(target, key) ? target[key] : null;
target[key] = cur && typeof cur === 'object' ? cur : {};
safeMerge(target[key], val);
} else {
target[key] = val;
}
}
return target;
}
Thinking it only matters in the browser, or fixing it by blocking the literal string in the raw request body.
Exposure: is it shipped to users or servers, or only used by build and test tools?
Fix path: update the parent package, or pin the child with overrides in package.json.
Compromised package: check if the bad version was ever installed, and rotate secrets it could see.
“First I'd check what the package does for us. I'd trace who pulls it in, and whether it ends up in the code we ship or only runs in the build or tests. A regex denial-of-service in a test tool is not the same as one in our request parser. If it ships and the vulnerable path is reachable, I'd hold the release. The fix is usually updating the parent package, and if the parent hasn't released yet, pinning the child to a patched version with overrides, then running the full test suite because we've forced a version the parent never tested. If the alert is a hijacked package rather than a bug, it's more serious: I'd check the lockfile history and CI logs for whether that version was ever installed, and rotate any tokens our CI exposes. Either way, I'd write down the decision so the next alert is quicker.”
// package.json: force a patched version of a nested dependency
{
"overrides": {
"vulnerable-lib": "^2.4.1"
}
}
Either running a forced audit fix that bumps majors on release day, or ignoring the alert because the app still works.
Access: a script on the page can read the whole DOM, including form fields.
Cost: load it async or deferred, measure its main-thread time, set a budget.
Control: Content Security Policy, consent, and a way to switch it off fast.
“I'd start by reminding everyone that a script on checkout can read every field on that page, so it's a security decision as well as a marketing one. I'd ask what it needs to measure. Often the thank-you page is enough, and it has no form fields. If it truly must be on checkout, I'd make sure it loads async so it can't block rendering, measure how much main-thread time it takes on a mid-range phone, and check it against our performance budget. It would go into our Content Security Policy explicitly, load only after the user's consent where that's required, and sit behind a switch we can turn off without a deploy. At my last company that conversation moved the tag to the confirmation page, marketing still got their numbers, and checkout stayed clean.”
Pasting the snippet into the page head because the request came from above.
Understand: ask what problem it solves today, not in theory.
Costs: calls you can't trace, misspelled event names that fail silently, listeners that leak.
Middle ground: direct calls inside a feature, events only where many listeners really exist.
“I'd start by asking what pain it fixes, because 'decoupled' sometimes just means the dependency is hidden. Then I'd be specific about costs. With a direct call, I can jump to the function and my editor finds every caller. With string event names, a typo means the event fires and nobody listens, and there's no error. The order listeners run in becomes a hidden rule, an error in one listener can get swallowed, and any component that subscribes but forgets to unsubscribe leaks. If there's a real case, like several unrelated widgets that must react to a user logging out, I'd suggest events just for that, with names as constants in one file and a subscribe helper that returns an unsubscribe function. I'd talk it through on a short call rather than leave twenty comments, and approve the smaller version.”
Approving it because it looks cleaner, or rejecting it with 'we don't do that here' and no reasons.
Agree: a short discussion to pick defaults, not a debate over every rule.
Adopt: format the codebase in one commit and keep that commit out of blame.
Enforce gently: run it in CI and on commit, and turn off rules that only make noise.
“Reviews on our team kept stalling on semicolons, quotes and line breaks, which wasted everyone's time. I proposed a formatter plus a linter focused on bugs, not taste. Instead of debating every rule, I suggested we take the formatter's defaults and spend one meeting only on the handful of lint rules that caused real disputes. I reformatted the whole repo in a single commit on a quiet afternoon and added it to the blame ignore file so history stayed useful. Then CI checked it, and a pre-commit hook fixed things automatically. A couple of rules produced lots of warnings with no real bugs behind them, so we turned those off after two weeks. Style comments in review basically disappeared, and the cost was one big diff and some merge pain for open branches that day.”
Pushing a strict rule set with hundreds of errors on everyone's open branches without asking anyone.
Guide: ask them to show what they tried, then set a breakpoint together.
Explain: a method passed as a callback loses its this, because this depends on how it's called.
Follow up: let them pick the fix and check in on the next similar bug.
“I sat with them and asked them to walk me through what they'd tried, which already showed me they were changing things at random. I suggested a breakpoint inside the method, and when it hit, I asked what this was. It was undefined, and then I asked how the method was being called. They'd passed this.handleSave straight to an event helper, so it was called as a plain function, not as a method. I explained the rule in one sentence: this is decided by how a function is called, not where it's written. Then I let them pick a fix, and they chose an arrow wrapper at the call site. It took longer than me just saying the answer, but a week later they found a similar bug on their own and told me about it afterwards.”
Taking the keyboard and fixing it silently, or leaving them alone because they should work it out.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your live interview audio and screen are never stored. Your resume and notes are saved to your account so the app fills them in on any computer. It stays out of screen share on every plan, including Free; only you can see it.