Microsoft Frontend Developer (Mid-Level) Interview Preparation Guide
Microsoft's frontend developer interview process for mid-level candidates consists of an initial recruiter screening followed by technical phone screens and multiple onsite rounds. The process evaluates coding proficiency, system design thinking, React/JavaScript expertise, algorithmic problem-solving, and cultural alignment. Mid-level candidates are expected to demonstrate ownership of projects, strong fundamentals, and the ability to discuss architectural trade-offs.
Interview Rounds
Recruiter Screening
What to Expect
Initial conversation with a Microsoft recruiter to assess background, motivation, and fit. This round covers your resume, experience with relevant technologies, understanding of the role, and career goals. The recruiter verifies your availability, salary expectations, and visa sponsorship needs if applicable. This is your opportunity to clarify what the role entails and understand Microsoft's team structure.
Tips & Advice
Be enthusiastic about web technologies and Microsoft's products. Clearly articulate your experience with React, JavaScript, and responsive design. Prepare 2-3 concrete examples of projects you've built that demonstrate frontend skills. Ask thoughtful questions about the team, product, and tech stack. Show genuine interest in learning and growth at Microsoft.
Focus Topics
Motivation and Fit
Articulate why you're interested in Microsoft, what excites you about the role, and how this opportunity aligns with your career goals.
Practice Interview
Study Questions
Technical Stack Familiarity
Demonstrate hands-on experience with React, JavaScript, HTML5, CSS, responsive design, and browser development tools. Mention any experience with REST APIs, testing frameworks, or state management libraries.
Practice Interview
Study Questions
Professional Background and Experience
Overview of your 2-5 years of frontend development experience, key projects, and measurable impact. Be ready to discuss technical decisions you made and results achieved.
Practice Interview
Study Questions
Technical Phone Screen - React and Component Design
What to Expect
First technical interview conducted over video/phone with a senior frontend engineer. You'll be asked to build a simple React component or UI feature with well-defined requirements. The focus is on your ability to think through component architecture, state management, and writing clean, maintainable code. You'll share your screen and code in real-time using a collaborative editor or CodePen-style environment. This round typically includes follow-up questions about your approach, potential optimizations, and edge cases.
Tips & Advice
Clarify requirements before coding. Think out loud as you structure your solution—discuss component hierarchy, props vs. state, and potential performance considerations. Write clean, readable code with proper naming conventions. Be prepared to refactor or add features on the fly. Explain your reasoning for choices like using useState vs. useContext. Ask for feedback and be open to suggestions. Common tasks include building a to-do list, autocomplete search, image carousel, or form with validation.
Focus Topics
Code Quality and Maintainability
Write self-documenting code with meaningful variable names, proper indentation, and logical structure. Discuss potential edge cases and error handling. Show awareness of performance (e.g., unnecessary re-renders).
Practice Interview
Study Questions
CSS Styling and Responsive Design
Write CSS-in-JS or traditional CSS for React components. Implement responsive layouts using flexbox/grid. Understand CSS specificity and how to scope styles to components.
Practice Interview
Study Questions
DOM Manipulation and Event Handling
Handle events in React (onClick, onChange, form submissions), understand event delegation, and work with refs when necessary. Know event propagation and how to prevent default behavior.
Practice Interview
Study Questions
State Management in React
Manage local component state with useState, handle side effects with useEffect, and know when to use Context API or external libraries like Redux. Understand the differences and trade-offs.
Practice Interview
Study Questions
React Component Architecture and Composition
Design functional components with proper separation of concerns, reusability, and testability. Understand when to use custom hooks, context, or prop drilling. For mid-level, demonstrate awareness of performance implications.
Practice Interview
Study Questions
Technical Phone Screen - Algorithms and Problem-Solving
What to Expect
Second technical interview focusing on algorithmic problem-solving and core data structures using JavaScript. You'll receive a LeetCode-style coding problem (typically Easy to Medium difficulty) and solve it in a collaborative editor. The interviewer evaluates your problem-solving approach, code correctness, time/space complexity awareness, and ability to optimize. For mid-level candidates, you're expected to arrive at working solutions efficiently and discuss trade-offs without requiring major hints.
Tips & Advice
Start by clarifying the problem and discussing your approach before coding. Mention time and space complexity. Write clean code incrementally and test with examples. If you get stuck, explain your thinking and ask for hints rather than staying silent. Common problem types include array/string manipulation, sorting, searching, linked lists, and basic tree problems. Practice on LeetCode; focus on understanding the solution pattern rather than memorizing.
Focus Topics
Problem-Solving Approach and Communication
Think out loud, ask clarifying questions, discuss your approach before coding, explain your logic, and handle edge cases. Show structured thinking.
Practice Interview
Study Questions
JavaScript Fundamentals for Algorithms
Work with arrays, objects, loops, conditionals, and sorting. Know built-in methods (map, filter, reduce, slice, split, etc.) and when to use them. Understand variable scope and closures in the context of problem-solving.
Practice Interview
Study Questions
Array and String Manipulation
Solve problems involving array operations (searching, sorting, two-pointer), string transformations, and substring/subsequence patterns. Understand when to use different approaches.
Practice Interview
Study Questions
Time and Space Complexity Analysis
Analyze algorithm efficiency using Big O notation. Discuss trade-offs between time and space. For mid-level, optimize from brute force to efficient solutions.
Practice Interview
Study Questions
Onsite Round 1 - Advanced React and UI Component Implementation
What to Expect
An in-depth technical round (onsite or virtual onsite) where you build a more complex React component or mini-application with multiple requirements and edge cases. This might involve creating a reusable UI component (like a dropdown, modal, or data table) with attention to accessibility, performance, and user experience. You'll be evaluated on architectural decisions, handling of complex state, and how you approach testing and edge cases. The interviewer may ask you to extend functionality or refactor code based on new requirements.
Tips & Advice
Start by understanding all requirements, including accessibility (ARIA attributes, keyboard navigation). Sketch out your component structure before coding. Use proper TypeScript/JSDoc types if offered. Consider error states, loading states, and edge cases. Write testable code. If asked to add a feature, refactor gracefully. Discuss performance optimizations (memoization, lazy loading). Be prepared to talk about how this component would integrate into a larger application. Show your design sense—components should be intuitive and follow platform conventions.
Focus Topics
API Integration and Data Fetching
Fetch data from REST APIs, handle loading/error states, implement error boundaries, and manage side effects. Know when to use useEffect vs. other patterns.
Practice Interview
Study Questions
Testing React Components
Write unit tests using testing libraries like React Testing Library. Test user interactions, rendering logic, and edge cases. Understand the difference between unit and integration tests.
Practice Interview
Study Questions
Form Handling and Validation
Build forms with validation logic, error handling, and user feedback. Handle field focus, blur, and submission. Manage form state efficiently.
Practice Interview
Study Questions
Performance Optimization Techniques
Identify and fix performance issues: unnecessary re-renders, inefficient state updates, large bundle sizes. Use React DevTools profiler. Discuss code splitting and lazy loading.
Practice Interview
Study Questions
Advanced React Patterns and Hooks
Master custom hooks, useReducer, useCallback, useMemo, and useContext. Understand when to optimize with memoization and when it's premature. Know the rules of hooks.
Practice Interview
Study Questions
Accessibility Standards and Implementation
Implement semantic HTML, ARIA attributes, keyboard navigation, focus management, and screen reader support. Know WCAG guidelines and how to test accessibility.
Practice Interview
Study Questions
Onsite Round 2 - Frontend System Design and Architecture
What to Expect
A design-focused technical round where you architect a complex frontend system or feature at a high level. You might be asked to design how you'd build a specific Microsoft feature (e.g., 'Design the emoji autocomplete feature in Microsoft Teams' or 'How would you build a collaborative document editor UI?'). The interviewer evaluates your ability to break down problems, discuss trade-offs, consider scalability and performance, and think about component architecture, state management, caching, and API design. You're expected to ask clarifying questions, propose multiple approaches, and justify your choices.
Tips & Advice
Ask clarifying questions first: What are the key requirements? How many users? What's the performance requirement? Start with a high-level architecture diagram or description. Discuss trade-offs openly (e.g., client-side caching vs. server-side). Consider edge cases, error handling, and offline behavior. For mid-level, you should show awareness of real-world constraints. Discuss how the frontend would integrate with backend services. Be open to feedback and willing to pivot if the interviewer introduces new constraints.
Focus Topics
Error Handling and Resilience
Design how the system handles network failures, API errors, timeouts, and edge cases. Implement graceful degradation and user-friendly error messages.
Practice Interview
Study Questions
API Design and Data Flow
Design how the frontend would request data from the backend. Discuss pagination, filtering, sorting, real-time updates, and error handling. Consider API efficiency and reducing network requests.
Practice Interview
Study Questions
Frontend Architecture and Scalability
Design component hierarchies, folder structures, and module organization for large applications. Discuss how to keep components maintainable as the codebase grows. Consider separation of concerns and modularity.
Practice Interview
Study Questions
State Management Strategy
Design state management for complex applications: where to keep state (component, context, external store), how to avoid prop drilling, and how to sync state across components. Compare approaches.
Practice Interview
Study Questions
Performance and Caching Strategies
Discuss caching (client-side caching, HTTP caching, service workers), code splitting, lazy loading, and pagination. Design for performance from the start. Consider memory constraints.
Practice Interview
Study Questions
Onsite Round 3 - Behavioral and Hiring Manager Round
What to Expect
Final round with a hiring manager or senior engineer focusing on behavioral assessment, culture fit, and team collaboration. This round explores your work style, communication, how you handle conflicts or setbacks, your approach to mentoring or helping junior developers, and your growth mindset. The interviewer assesses whether you work well in a collaborative environment, take ownership of problems, and align with Microsoft's values (innovation, customer focus, growth mindset). You'll also have the opportunity to ask questions about the team, role, and career growth at Microsoft.
Tips & Advice
Prepare specific STAR method stories that demonstrate collaboration, conflict resolution, learning from failure, and impact. Talk about a time you mentored someone, owned a difficult project, or advocated for a better technical decision. Emphasize growth mindset and continuous learning. Be authentic and genuine. Ask thoughtful questions about the team's technical challenges, growth opportunities, and how success is measured. Show interest in how your work impacts Microsoft's customers. Discuss how you'd contribute to your team beyond just coding—code reviews, documentation, pairing with junior developers, etc.
Focus Topics
Growth Mindset and Learning
Discuss how you stay current with frontend trends, handle technologies you don't know, approach learning challenges, and get feedback. Share your learning plan.
Practice Interview
Study Questions
Mentoring and Knowledge Sharing
Share examples of helping junior developers, code reviews you've conducted, knowledge you've shared, or initiatives you've led. Show you invest in your team's growth.
Practice Interview
Study Questions
Handling Ambiguity and Pressure
Share a story where requirements weren't clear, priorities shifted, or you faced a tight deadline. Describe your approach, how you communicated, and the outcome.
Practice Interview
Study Questions
Ownership and Accountability
Share examples of projects where you took end-to-end ownership, made key technical decisions, and drove results. Discuss how you handle setbacks and learn from failures.
Practice Interview
Study Questions
Collaboration and Communication
Describe how you work with designers, backend developers, product managers, and other frontend engineers. Share examples of resolving disagreements, giving feedback, and explaining technical concepts to non-technical stakeholders.
Practice Interview
Study Questions
Frequently Asked Frontend Developer Interview Questions
Before presenting a piece of work to a room, anticipate three tough questions someone might ask, and prepare a concise, one to two sentence answer for each.
Sample Answer
Direct answer
Before presenting, think through the questions a skeptical, informed listener would actually ask, prioritizing the ones that probe your weakest assumption or your most surprising claim, and prepare a short, direct answer for each rather than hoping you'll improvise well.
Structured elaboration
- Look for your weakest link first. Every piece of work has at least one assumption, data limitation, or judgment call that's more debatable than the rest; that's almost always where a sharp question comes from.
- Look for your most surprising or counterintuitive claim. Anything that contradicts what people expected invites a "how do you know that's really true?" question.
- Prepare a one-to-two sentence answer, not a rehearsed speech. A concise, direct answer reads as confident; a long, defensive one reads as though you're worried about the question.
- It's fine to prepare an honest "we don't know yet" answer for a genuine gap, rather than inventing a more impressive-sounding answer under pressure; a confident admission of a limitation is usually better received than an unconvincing dodge.
- Practice saying the answers out loud, not just thinking through them mentally; the gap between a mentally-rehearsed answer and one you can actually say smoothly under pressure is often bigger than expected.
Worked example
Presenting a recommendation to shift budget from one marketing channel to another based on eight weeks of data: anticipated tough questions might be "how confident are you this isn't just seasonal?", "what happens if the trend reverses next month?", and "did you control for the pricing change that happened in week 5?" Prepared answers: "We checked against the same period last year and saw a similar pattern, though eight weeks is admittedly a short window;" "if it reverses, the downside is limited since we're proposing a 20% shift, not the full budget;" "we did exclude the two weeks around the pricing change specifically to avoid conflating the two effects."
Each answer is short, direct, and, where there's a genuine limitation (the short time window), honestly acknowledged rather than glossed over.
Trade-offs and pitfalls
- Over-preparing for every conceivable question can lead to over-rehearsed, stiff-sounding answers; focus on the two or three questions most likely to actually come up, not an exhaustive list.
- Being defensive about a genuinely fair question damages credibility more than the limitation itself would; a calm, honest acknowledgment of a real gap usually lands better than an unconvincing justification.
- If a question comes up that you genuinely didn't anticipate and don't know the answer to, saying so plainly and offering to follow up is stronger than guessing in the moment.
Implement a responsive navigation bar using HTML and CSS only (no JavaScript). Requirements: On mobile (<768px) the navigation collapses into a hamburger icon that toggles a vertical menu using the checkbox hack; on >=768px the navigation displays horizontally. Ensure the pattern is keyboard-accessible and provide the HTML and CSS you would write.
Sample Answer
Direct answer
Put a visually hidden <input type="checkbox"> before the menu, tie a <label> to it as the hamburger button, and let the :checked pseudo-class (matches a ticked checkbox) plus the general sibling combinator ~ (selects every later sibling of the same parent, not only the next one) reveal the menu. Write the collapsed layout as the default and add one @media (min-width: 768px) block that shows the links in a row and removes the toggle. Keyboard access depends on three details: the checkbox must stay focusable (never display: none), the label needs a visible focus style, and the collapsed list must be display: none so its links leave the tab order.
The code
index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nav demo</title>
<link rel="stylesheet" href="nav.css">
</head>
<body>
<header class="site-header">
<a class="logo" href="/">Acme</a>
<input type="checkbox" id="nav-toggle" class="nav-toggle">
<label for="nav-toggle" class="nav-button">Menu</label>
<nav class="nav" aria-label="Main">
<ul class="nav-list">
<li><a href="/products">Products</a></li>
<li><a href="/pricing">Pricing</a></li>
<li><a href="/docs">Docs</a></li>
</ul>
</nav>
</header>
<main><h1>Welcome</h1></main>
</body>
</html>
nav.css:
/* Mobile first: this is the collapsed layout */
.site-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 8px 16px; }
.logo { font-weight: 700; }
/* Visually hidden but still focusable: never display:none or visibility:hidden here */
.nav-toggle {
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0;
overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}
.nav-button { display: inline-block; padding: 8px 12px; border: 1px solid currentColor; border-radius: 4px; cursor: pointer; }
.nav-toggle:focus-visible + .nav-button { outline: 3px solid #1a5fd0; outline-offset: 2px; }
.nav { flex-basis: 100%; }
.nav-list { display: none; margin: 0; padding: 0; list-style: none; } /* collapsed: out of layout AND out of the tab order */
.nav-toggle:checked ~ .nav .nav-list { display: block; } /* open: vertical menu */
.nav-list a { display: block; padding: 12px 8px; }
@media (min-width: 768px) {
.nav-toggle, .nav-button { display: none; } /* no toggle needed */
.nav { flex-basis: auto; }
.nav-list,
.nav-toggle:checked ~ .nav .nav-list { display: flex; gap: 8px; } /* same specificity as the open rule, later in the file, so it wins */
}
How it works
- The input is first in tab order after the logo, and a click on the label toggles it because
for="nav-toggle"points at itsid. Pressing Space on the focused checkbox does the same, so the keyboard path needs no JavaScript. - The checkbox is shrunk to one pixel and clipped, which hides it visually but keeps it focusable.
display: noneorvisibility: hiddenwould remove it from the tab order and the keyboard user could never open the menu. - The checkbox sits right before the label, so
.nav-toggle:focus-visible + .nav-button(the adjacent sibling) draws a focus ring on the label whenever the hidden input has keyboard focus. The adjacent sibling combinator+matches only the element immediately after the input, which the label is;~would match any later sibling, which is what the menu needs because the<nav>comes after the label. .nav-list { display: none }removes the collapsed links from rendering and from the tab order..nav-toggle:checked ~ .nav .nav-listshows them as a vertical block.- At 768px and wider, the toggle and label are hidden and the list is
display: flex(row).min-width: 768pxmeans 768 and above, so "below 768px" is the unqueried default and nothing falls between the two ranges.
Specificity detail that decides whether it works
Specificity is the score the cascade uses to pick between rules. It is a triple counted left to right: ids, then classes plus attribute selectors plus pseudo-classes, then element names, compared slot by slot (so one id beats any number of classes). The open rule .nav-toggle:checked ~ .nav .nav-list has three classes (.nav-toggle, .nav, .nav-list) and one pseudo-class (:checked), so it scores (0,4,0), higher than a plain .nav-list at (0,1,0). If the desktop block only said .nav-list { display: flex }, a ticked checkbox on a phone that is then rotated to a wide screen would keep the old display: block (the highest-scoring rule wins) and the desktop menu would stack. The desktop block therefore lists the same long selector as well; it ties at (0,4,0) and wins by being later in the file. The check below reproduces exactly that case.
Drawer on small screens
The same checkbox can drive a drawer that slides in from the side instead of dropping down. Load this after nav.css:
/* Drawer variant: loaded after nav.css, so on small screens it overrides the dropdown rules */
.nav {
position: fixed; inset: 0 auto 0 0; width: min(80vw, 320px); background: #fff; box-shadow: 2px 0 8px rgba(0,0,0,.3);
transform: translateX(-100%);
visibility: hidden; /* off-screen links must also leave the tab order */
transition: transform .25s ease, visibility 0s linear .25s;
}
.nav-list { display: block; margin: 0; padding: 56px 0 0; list-style: none; }
.nav-toggle:checked ~ .nav { transform: none; visibility: visible; transition-delay: 0s; }
@media (prefers-reduced-motion: reduce) { .nav { transition: none; } }
@media (min-width: 768px) {
.nav { position: static; width: auto; transform: none; visibility: visible; box-shadow: none; background: none; }
.nav-list { display: flex; gap: 8px; padding: 0; }
}
Reading the rules: inset: 0 auto 0 0 is shorthand for top 0, right auto, bottom 0, left 0, so the panel is pinned to the left edge at full height; width: min(80vw, 320px) takes the smaller of 80% of the viewport width and 320px; transform: translateX(-100%) slides the panel left by its own width, so it sits just off-screen. Checking the box sets transform: none, which slides it back. The transition shorthand animates transform for .25s and gives visibility a 0s duration with a .25s delay, so on closing the panel flips to hidden only once the slide has finished; the checked rule sets transition-delay: 0s, so on opening it becomes visible at once. The @media (min-width: 768px) block turns the panel back into an ordinary in-flow nav (position: static) so the desktop row layout is unchanged.
The important line is visibility: hidden on the closed drawer. Moving the panel off-screen with transform does not remove its links from the tab order, so keyboard users would tab into links they cannot see. visibility: hidden does remove them, and the transition on visibility is delayed by the animation length so the panel stays visible while it slides out. The prefers-reduced-motion block turns the animation off for users who asked the system for less motion.
Keyboard and screen-reader behaviour, and the limits of the pattern
- Enter does not toggle a checkbox. Space does. A user who expects a menu button to respond to Enter gets nothing. The check below confirms this in all three engines (Chromium, Firefox and WebKit, the engines Playwright ships). The native alternative that responds to both keys, with no JavaScript, is
<details><summary>Menu</summary>...</details>; in the same three engines Enter on the focused<summary>toggled it and Space toggled it too (a second script, shown below). - The semantics are those of a checkbox. In the accessibility tree the control is
role: checkbox,name: Menu,checked: false(the check below reads it in Chromium, Firefox and WebKit; Firefox leaves thecheckedproperty out when it is false, so the check only requires that it is not true). The accessibility tree is the structure a browser hands to screen readers: each element's role (what it is), name (its label) and state. A disclosure button (a button that shows or hides content) is what a menu toggle really is, and it would also announce expanded or collapsed througharia-expanded(an attribute set totrueorfalsethat tells assistive technology whether the controlled content is currently open); that needs JavaScript to keep in sync. The pure-CSS version cannot do it. - No Escape key and no auto-close. CSS cannot listen for Escape, and tapping an in-page link such as
#pricingleaves the checkbox ticked, so the menu stays open over the content. Both need a few lines of script if they matter. - Progressive enhancement. Because the menu is pure HTML and CSS, it works with JavaScript disabled or failing, and a script can be layered on later for Escape and
aria-expanded.
Run the checks below to see these confirmed. The harness opens the page in Chromium, Firefox and WebKit (Playwright's WebKit build, which is not Safari on iOS), uses only keyboard presses for the interactions, reads getComputedStyle and getBoundingClientRect, and exits non-zero on any failure.
nav-check.mjs:
import fs from 'node:fs';
import { chromium, firefox, webkit } from 'playwright';
const dir = 'nav/';
let css = fs.readFileSync(dir + 'nav.css', 'utf8');
if (process.env.DROP === 'hidden-input') css = css.replace('overflow: hidden; clip-path: inset(50%);', 'display: none;');
if (process.env.DROP === 'specificity') css = css.replace(' .nav-list,\n .nav-toggle:checked ~ .nav .nav-list {', ' .nav-list {');
if (process.env.DROP === 'desktop') css = css.replace(/@media \(min-width: 768px\) \{[\s\S]*\}\s*$/, '');
const drawer = fs.readFileSync(dir + 'drawer.css', 'utf8');
const html = fs.readFileSync(dir + 'index.html', 'utf8').replace('<link rel="stylesheet" href="nav.css">', '');
const dropVis = process.env.DROP === 'visibility';
const make = (extra) => html.replace('</head>', `<style>${css}${extra}</style></head>`);
const dropdownPage = make('');
const drawerPage = make(drawer.replace(dropVis ? 'visibility: hidden;' : '@@never@@', ''));
let bad = 0;
const check = (name, label, pass, detail = '') => {
if (!pass) bad++;
if (name === 'chromium' || !pass) console.log(name.padEnd(8), label.padEnd(34), detail, pass ? 'ok' : 'FAIL');
};
const active = (p) => p.evaluate(() => { const a = document.activeElement; return a === document.body ? 'body' : (a.id || a.textContent.trim()); });
const insideNav = (p) => p.evaluate(() => !!document.activeElement.closest('nav'));
for (const [name, type] of [['chromium', chromium], ['firefox', firefox], ['webkit', webkit]]) {
const browser = await type.launch();
const p = await browser.newPage();
// Mobile, dropdown menu, keyboard only
await p.setViewportSize({ width: 767, height: 800 });
await p.setContent(dropdownPage);
const listShown = () => p.evaluate(() => getComputedStyle(document.querySelector('.nav-list')).display);
check(name, '767px closed: list display', (await listShown()) === 'none', await listShown());
await p.keyboard.press('Tab'); const first = await active(p);
await p.keyboard.press('Tab'); const second = await active(p);
check(name, 'Tab order: logo, then checkbox', first === 'Acme' && second === 'nav-toggle', `${first} > ${second}`);
await p.keyboard.press('Tab');
check(name, 'closed: Tab skips hidden links', !(await insideNav(p)), 'focus is outside nav');
await p.setContent(dropdownPage); // fresh page, then Tab, Tab to reach the checkbox again
await p.keyboard.press('Tab'); await p.keyboard.press('Tab');
await p.keyboard.press('Enter');
check(name, 'Enter does not toggle a checkbox', (await listShown()) === 'none', 'still closed after Enter');
await p.keyboard.press('Space');
check(name, 'Space opens (block list)', (await listShown()) === 'block', await listShown());
await p.keyboard.press('Tab');
check(name, 'open: Tab enters first link', (await active(p)) === 'Products', await active(p));
const ys = await p.evaluate(() => [...document.querySelectorAll('.nav-list a')].map(a => a.getBoundingClientRect().top));
check(name, 'open: links stacked vertically', ys[0] < ys[1] && ys[1] < ys[2], ys.map(Math.round).join(','));
{ // accessibility tree: the same snapshot call works in all three engines
await p.setContent(dropdownPage);
const snap = await p.accessibility.snapshot({ interestingOnly: false });
const find = (n) => n.role === 'checkbox' ? n : (n.children || []).map(find).find(Boolean);
const cb = find(snap);
check(name, 'a11y tree: checkbox named Menu', !!cb && cb.name === 'Menu' && !cb.checked, JSON.stringify(cb ? { role: cb.role, name: cb.name, checked: cb.checked } : 'no checkbox in the tree'));
}
// Desktop
for (const [w, horizontal] of [[768, true], [1280, true]]) {
await p.setViewportSize({ width: w, height: 800 });
await p.setContent(dropdownPage);
const m = await p.evaluate(() => ({
list: getComputedStyle(document.querySelector('.nav-list')).display,
button: getComputedStyle(document.querySelector('.nav-button')).display,
ys: [...document.querySelectorAll('.nav-list a')].map(a => Math.round(a.getBoundingClientRect().top)) }));
check(name, `${w}px: horizontal, no button`, m.list === 'flex' && m.button === 'none' && new Set(m.ys).size === 1, JSON.stringify(m));
await p.keyboard.press('Tab'); await p.keyboard.press('Tab');
check(name, `${w}px: second Tab reaches Products`, (await active(p)) === 'Products', await active(p));
}
// Desktop with the box left checked on mobile: still horizontal
await p.setViewportSize({ width: 767, height: 800 }); await p.setContent(dropdownPage);
await p.keyboard.press('Tab'); await p.keyboard.press('Tab'); await p.keyboard.press('Space'); // open it with the keyboard
await p.setViewportSize({ width: 1024, height: 800 });
check(name, 'checked then widened: still flex', (await p.evaluate(() => getComputedStyle(document.querySelector('.nav-list')).display)) === 'flex');
// Drawer variant
await p.setViewportSize({ width: 375, height: 800 });
await p.setContent(drawerPage);
const rect = () => p.evaluate(() => { const r = document.querySelector('.nav').getBoundingClientRect(); return { left: r.left, right: r.right }; });
const closed = await rect();
check(name, 'drawer closed: off-screen', closed.right <= 0, `right=${closed.right}`);
await p.keyboard.press('Tab'); await p.keyboard.press('Tab'); await p.keyboard.press('Tab');
check(name, 'drawer closed: links not focusable', !(await insideNav(p)));
await p.setContent(drawerPage);
await p.keyboard.press('Tab'); await p.keyboard.press('Tab');
await p.keyboard.press('Space');
await p.waitForFunction(() => document.querySelector('.nav').getBoundingClientRect().left === 0, null, { timeout: 2000 }).catch(() => {}); // a miss shows up as a failed check below
const opened = await rect();
check(name, 'drawer open: on-screen', opened.left === 0 && opened.right === 300, `left=${opened.left} right=${opened.right}`);
await p.keyboard.press('Tab');
check(name, 'drawer open: Tab enters first link', (await active(p)) === 'Products', await active(p));
await browser.close();
}
console.log(bad ? `${bad} failing checks` : 'all checks pass in chromium, firefox and webkit');
process.exitCode = bad ? 1 : 0;
Run in the Playwright 1.48.2 container, it prints (Chromium rows; Firefox and WebKit print only failures):
chromium 767px closed: list display none ok
chromium Tab order: logo, then checkbox Acme > nav-toggle ok
chromium closed: Tab skips hidden links focus is outside nav ok
chromium Enter does not toggle a checkbox still closed after Enter ok
chromium Space opens (block list) block ok
chromium open: Tab enters first link Products ok
chromium open: links stacked vertically 52,94,136 ok
chromium a11y tree: checkbox named Menu {"role":"checkbox","name":"Menu","checked":false} ok
chromium 768px: horizontal, no button {"list":"flex","button":"none","ys":[16,16,16]} ok
chromium 768px: second Tab reaches Products Products ok
chromium 1280px: horizontal, no button {"list":"flex","button":"none","ys":[16,16,16]} ok
chromium 1280px: second Tab reaches Products Products ok
chromium checked then widened: still flex ok
chromium drawer closed: off-screen right=0 ok
chromium drawer closed: links not focusable ok
chromium drawer open: on-screen left=0 right=300 ok
chromium drawer open: Tab enters first link Products ok
all checks pass in chromium, firefox and webkit
Hand check: the three stacked links sit 42px apart (52, 94, 136) because each link is 12px + 12px of padding plus the default font's 18px line (the line height depends on the font, so this number can differ on another machine), and the row layout puts all three at the same top edge (16, 16, 16). The drawer is min(80vw, 320px) wide, which at a 375px viewport is min(300, 320) = 300px, matching right=300.
Each mutation makes the harness fail, selected with the DROP environment variable the script reads: hidden-input replaces the one-pixel hiding with display: none on the input, desktop deletes the desktop media block, specificity shortens the desktop selector to plain .nav-list, and visibility deletes visibility: hidden from the drawer. The failing lines (Chromium rows; Firefox and WebKit fail the same checks) are:
DROP=hidden-input
chromium Tab order: logo, then checkbox Acme > body FAIL
chromium Space opens (block list) none FAIL
chromium open: Tab enters first link Acme FAIL
chromium open: links stacked vertically 0,0,0 FAIL
chromium a11y tree: checkbox named Menu "no checkbox in the tree" FAIL
chromium drawer open: on-screen left=-300 right=0 FAIL
chromium drawer open: Tab enters first link Acme FAIL
DROP=desktop
chromium 768px: horizontal, no button {"list":"none","button":"block","ys":[0,0,0]} FAIL
chromium 768px: second Tab reaches Products nav-toggle FAIL
chromium 1280px: horizontal, no button {"list":"none","button":"block","ys":[0,0,0]} FAIL
chromium 1280px: second Tab reaches Products nav-toggle FAIL
chromium checked then widened: still flex FAIL
DROP=specificity
chromium checked then widened: still flex FAIL
DROP=visibility
chromium drawer closed: links not focusable FAIL
With the input removed from rendering, the second Tab no longer lands on a checkbox (it goes to body in Chromium and WebKit and stays on Acme in Firefox), the accessibility tree contains no checkbox, and the drawer never opens. Every run that has a failing check ends with exit status 1 (the script sets process.exitCode), so node nav-check.mjs && node details-check.mjs stops at the first failure. With the desktop block gone, the list is still none and the toggle button is still block at 768px. Re-running the unmodified harness ten times in a row printed the success line every time.
The <details> comparison. Each line prints the open state of the element after Enter, then after Space, starting from a fresh page in each engine:
import { chromium, firefox, webkit } from 'playwright';
const page = '<details id="d"><summary>Menu</summary><ul><li><a href="/a">A</a></li></ul></details>';
for (const [name, type] of [['chromium', chromium], ['firefox', firefox], ['webkit', webkit]]) {
const b = await type.launch(); const p = await b.newPage();
await p.setContent(page);
const open = () => p.evaluate(() => document.getElementById('d').open);
await p.keyboard.press('Tab');
await p.keyboard.press('Enter'); const afterEnter = await open();
await p.keyboard.press('Space'); const afterSpace = await open();
console.log(name.padEnd(8), `after Enter open=${afterEnter}, after Space open=${afterSpace}`);
await b.close();
}
chromium after Enter open=true, after Space open=false
firefox after Enter open=true, after Space open=false
webkit after Enter open=true, after Space open=false
The open value after Space is false because Enter had already opened the element and Space closed it again: each key toggles it.
Pitfalls
- Using
display: noneon the checkbox is the most common bug and makes the menu unreachable by keyboard. - Writing the mobile query as
max-width: 767pxand the desktop one asmin-width: 768pxis safe at whole pixels but leaves a gap at fractional widths such as 767.5px. - Forgetting a visible focus style: the focused element is invisible, so the label must show the ring.
- Labelling the control only with an icon: keep real text ("Menu") in the label so the checkbox gets an accessible name from it, as the accessibility-tree line shows.
Running the code
mkdir nav-demo && cd nav-demo && mkdir nav
# save index.html, nav.css and drawer.css into nav/, and nav-check.mjs and details-check.mjs here
echo '{"type":"module"}' > package.json
npm i playwright@1.48.2 # run inside mcr.microsoft.com/playwright:v1.48.2-jammy so the browsers exist
node nav-check.mjs && node details-check.mjs
DROP=hidden-input node nav-check.mjs # also: desktop, specificity, visibility
Describe flame graphs and how to read them to identify CPU hotspots. Explain what the X and Y axes represent, what wide vs tall flames indicate, and how inlined functions or shared libraries affect interpretation when prioritizing optimizations.
Sample Answer
What a flame graph is
A flame graph is a visualization built from many CPU stack samples collapsed together, showing which call stacks were running and how often.
The axes
- X-axis is NOT time. Boxes are typically sorted alphabetically (or by tool convention) left to right, and a box's WIDTH represents how often that function appeared across all samples, i.e. its share of total CPU time. Wide = expensive.
- Y-axis is call-stack depth. The bottom row is the root (e.g. a thread's entry point or
main), and each box stacked directly above another is a function CALLED BY the box below it. Moving up the stack means descending deeper into callees.
Wide vs tall
- A wide, flat "plateau" (a box that is wide but has no boxes stacked above it, or only thin ones) means that function itself is doing expensive work, it is a leaf-level hotspot.
- A tall, narrow "tower" (many boxes stacked on top of each other, each fairly narrow) shows a deep call chain, lots of nested calls, but tallness alone does not mean expensive; a narrow tower can represent very little total CPU time even though it looks visually striking.
How inlining and shared libraries distort the picture
- A JIT (Just-In-Time compiler, one that compiles code to machine instructions while the program runs) or an ahead-of-time compiler will often inline small, frequently-called functions directly into their caller for speed. An inlined function may not appear as its own box at all, its cost gets folded into the caller, making the caller look more expensive than its own source code would suggest.
- Frames from shared libraries without debug symbols can show up as raw addresses or a single generic library name (e.g. everything collapsing into one
libcbox), hiding what is really many distinct functions behind one wide box. Loading proper symbol tables (or using a profiler build with symbols) is often needed before a flame graph is trustworthy for prioritization.
Worked example
Picture a flame graph with main at the bottom spanning 100% of the width, calling handle_request (also ~100% width, since it wraps almost the whole request), which splits into two children: parse_json (10% width) and render_template (85% width). Inside render_template, there is a leaf box format_currency at 60% width with nothing stacked above it. That plateau tells you format_currency itself, not something it calls, is the single best optimization target, it accounts for more CPU than everything else in the request combined.
You've concluded that a legacy or troubled system needs to be retired or fundamentally rebuilt, but leadership isn't yet convinced it's worth the cost. Build the case and the plan: quantify the risk or cost of doing nothing, propose a phased approach with milestones, staffing, and a fallback option, and define the KPIs you'd use to know the effort succeeded and ownership can be handed off.
Sample Answer
Direct answer
Win this argument by translating "it's old and risky" into a number leadership already cares about, the cost or risk of doing nothing, then pair it with a phased plan that has a real fallback option, so the ask isn't all-or-nothing, and key performance indicators (KPIs, the specific measures used to judge success) that let leadership know later, objectively, whether the bet paid off.
Structured elaboration
- Quantify the cost of inaction: estimate current cost (maintenance hours, licensing, incident time) and risk exposure, as an honest range, not a falsely precise number.
- Phased plan: stages that each deliver something independently useful, so leadership sees value before everything is done.
- Milestones: a concrete checkpoint per phase with an explicit go/no-go decision.
- Staffing: name the team and time commitment, and where it comes from, since being unstaffed is the most common reason these efforts stall.
- Fallback: an explicit plan B if the full rebuild doesn't get funded or stalls partway.
- KPIs and handoff: two or three measurable success criteria plus a named owner once migrated, so "done" has a clear definition.
Worked example
A legacy reporting system costs the team an estimated 15 engineer-hours a week in firefighting, about 780 hours a year, and caused 4 data-accuracy incidents last year at roughly two days each to trace and fix. Cost of inaction: 780 hours a year in maintenance alone, before incident time or the risk of a fifth incident hitting an externally-reported number. Phased plan: Phase 1 (6 weeks) migrates the 3 highest-traffic reports as a parallel run, comparing output for parity; Phase 2 (8 weeks) migrates the remaining 12 once parity is proven; Phase 3 (2 weeks) decommissions the old system. Go/no-go at the end of Phase 1: pause if the new pipeline doesn't match within an agreed tolerance. Staffing: 1 dedicated engineer plus 20% of a second, for 16 weeks. Fallback: if the second engineer isn't funded, Phase 1 alone still cuts an estimated 60% of firefighting time, since those 3 reports account for most of it. KPIs: firefighting hours under 5 a week (from 15) and zero data-accuracy incidents in the following two quarters, reviewed at a 90-day check where ownership formally transfers.
Trade-offs and pitfalls
A suspiciously exact dollar figure instead of an honest range invites justified pushback. A phased plan with no independent value per phase means leadership sees nothing until it's all done, a hard sell. And no named owner after cutover lets the new system quietly become whoever built it's permanent side project.
You are building a multi-step sign-up form with inline validation. How do you implement the labels, inline error messages, focus order and ARIA roles so the form works with a screen reader and with the keyboard alone across every step, and how do you announce a step's errors to assistive technology?
Sample Answer
Direct answer
Make every control carry a real <label>, tie each error message to its field with aria-invalid and aria-describedby, and treat each step change as a navigation event for assistive technology (AT, such as a screen reader): after every "Next" or "Back" move keyboard focus to the new step's heading. When validation fails, show a summary of the errors at the top of the step and move focus to it; the summary's links jump to the offending fields. Keep the DOM order identical to the visual order so the Tab key walks the form top to bottom without any tabindex greater than 0.
How each requirement is met
| Requirement | Implementation |
|---|---|
| Labels | <label htmlFor> pointing at the input id (generated with React's useId so ids stay unique). Placeholder text is never the label. Clicking the label focuses the input. |
| Inline error messages | A <p id="...-err"> rendered next to the field only when the field has an error, referenced by aria-describedby, plus aria-invalid="true" on the input. |
| When errors appear | Not while the user is still typing a first value. Errors are revealed on a "Next" attempt (and clear as soon as the value becomes valid). MDN advises not setting aria-invalid="true" on empty required fields until a submit attempt, because the user may still be filling them in. |
| Focus order | Native elements in DOM order: fields, then "Back", then "Next". Only form controls and links are focusable, so the Tab key needs no custom handling. |
| ARIA roles | Few are needed: native <form>, <label>, <input>, <button>, <h2> already have the right semantics. ARIA is added in exactly five places: aria-invalid and aria-describedby on a field with a visible error, aria-labelledby on the form (so it is named by the step heading), role="alert" on the error summary, and role="status" on the region for the final success message. |
| Moving between steps | The <h2> for the step has tabIndex={-1} (focusable by script, not in the Tab sequence). After a successful step change the app calls .focus() on it, so the screen reader reads "Step 2 of 2: Profile" and the next Tab lands on the first field of the step. Without this, focus stays on the submit button (in this component the same element, relabelled "Create account" on the last step; in a wizard that swaps its buttons it would be a removed element and focus would fall back to the page), and the screen reader user is never told that the step changed. |
| Announcing a step's errors | On a failed "Next", focus moves to an error summary container (tabIndex={-1}) that contains a role="alert" element listing the problems; each item is a link to the field. This is the pattern the GOV.UK Design System documents: it says to move keyboard focus to the error summary, and its summary puts role="alert" on an element nested inside the focused root. |
| Keyboard alone | Everything is a button, link or input. Enter in a text field submits the form (the "Next" button is the form's default submit button). Summary links call .focus() on the field and preventDefault() so the page does not jump or change the URL. |
Two choices worth defending in the room: (1) step titles are announced through focus on the heading, not through a second aria-live region, because focus plus a live region carrying the same text risks the screen reader speaking it twice; (2) the form uses noValidate (an attribute that switches off the browser's built-in validation) so the app, not the browser's popup bubbles, owns the error text, the summary and the focus.
A live region is an element a screen reader watches: when its text changes, the reader speaks the new text without keyboard focus moving. role="alert", role="status" and the aria-live attribute create one; alert is assertive (it interrupts what is being read) and status is polite (it waits for a pause). That is why the same words must not arrive twice, once through a focused heading and once through a live region: the user would hear them twice.
Worked example: a two-step sign-up
Step 1 collects email and password, step 2 collects a name. This component was rendered under React 18 and jsdom (a simulated browser DOM) in a node:22 container with Testing Library, and the test drives it with simulated user events. The file dom-env.mjs, listed under Running the code at the end, creates the browser-like environment: it copies the jsdom window's globals (document, window, Event and so on) onto Node's global object so React and Testing Library can run. The form itself does not depend on it.
The component:
import { useId, useRef, useState, useEffect, type FormEvent } from 'react';
type Values = { email: string; password: string; name: string };
type Errors = Partial<Record<keyof Values, string>>;
const STEPS = [
{ title: 'Account', fields: ['email', 'password'] as const },
{ title: 'Profile', fields: ['name'] as const },
];
function validate(v: Values): Errors {
const e: Errors = {};
if (!/^\S+@\S+\.\S+$/.test(v.email)) e.email = 'Enter an email address like name@example.com.';
if (v.password.length < 8) e.password = 'Password must be at least 8 characters.';
if (!v.name.trim()) e.name = 'Enter your name.';
return e;
}
const LABELS: Record<keyof Values, string> = { email: 'Email', password: 'Password', name: 'Full name' };
export function SignupWizard({ manageFocus = true, onDone }: { manageFocus?: boolean; onDone?: (v: Values) => void }) {
const uid = useId();
const [step, setStep] = useState(0);
const [values, setValues] = useState<Values>({ email: '', password: '', name: '' });
const [shown, setShown] = useState<Errors>({}); // errors the user has earned the right to see
const [announcement, setAnnouncement] = useState('');
const summaryRef = useRef<HTMLDivElement>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
const focusTarget = useRef<'summary' | 'heading' | null>(null);
const current = STEPS[step];
const all = validate(values);
const stepErrors = Object.fromEntries(current.fields.filter((f) => all[f]).map((f) => [f, all[f]])) as Errors;
const errorKeys = Object.keys(shown) as (keyof Values)[];
// Focus moves after the DOM for the new state exists.
useEffect(() => {
if (!manageFocus) return;
if (focusTarget.current === 'summary') summaryRef.current?.focus();
if (focusTarget.current === 'heading') headingRef.current?.focus();
focusTarget.current = null;
});
function next(e: FormEvent) {
e.preventDefault();
if (Object.keys(stepErrors).length) {
setShown(stepErrors);
setAnnouncement('');
focusTarget.current = 'summary';
return;
}
setShown({});
if (step === STEPS.length - 1) {
onDone?.(values);
setAnnouncement('Account created.');
return;
}
setStep(step + 1);
focusTarget.current = 'heading';
}
function back() {
setShown({});
setStep(step - 1);
focusTarget.current = 'heading';
}
return (
<form onSubmit={next} noValidate aria-labelledby={`${uid}-h`}>
<div role="status" data-testid="announcer">{announcement}</div>
<h2 id={`${uid}-h`} tabIndex={-1} ref={headingRef}>
Step {step + 1} of {STEPS.length}: {current.title}
</h2>
{errorKeys.length > 0 && (
<div ref={summaryRef} tabIndex={-1} data-testid="summary">
<div role="alert">
<p>
{errorKeys.length === 1 ? 'There is 1 problem' : `There are ${errorKeys.length} problems`}
</p>
<ul>
{errorKeys.map((k) => (
<li key={k}>
<a
href={`#${uid}-${k}`}
onClick={(ev) => {
ev.preventDefault();
document.getElementById(`${uid}-${k}`)?.focus();
}}
>
{shown[k]}
</a>
</li>
))}
</ul>
</div>
</div>
)}
{current.fields.map((k) => {
const id = `${uid}-${k}`;
const err = shown[k];
return (
<div key={k}>
<label htmlFor={id}>{LABELS[k]}</label>
<input
id={id}
name={k}
type={k === 'password' ? 'password' : k === 'email' ? 'email' : 'text'}
autoComplete={k === 'password' ? 'new-password' : k === 'email' ? 'email' : 'name'}
value={values[k]}
aria-invalid={err ? true : undefined}
aria-describedby={err ? `${id}-err` : undefined}
onChange={(ev) => {
const v = { ...values, [k]: ev.target.value };
setValues(v);
// an already-shown error clears the moment the value becomes valid
if (shown[k] && !validate(v)[k]) setShown(({ [k]: _gone, ...rest }) => rest);
}}
/>
{err && <p id={`${id}-err`}>{err}</p>}
</div>
);
})}
{step > 0 && <button type="button" onClick={back}>Back</button>}
<button type="submit">{step === STEPS.length - 1 ? 'Create account' : 'Next'}</button>
</form>
);
}
How the focus and error logic reads. A handler cannot call .focus() straight after setStep, because the element to focus (the summary, or the next step's heading) does not exist until React has re-rendered. So the handler records the destination in focusTarget.current (a ref, which can change without causing a render), and the useEffect with no dependency list runs after every render, focuses the recorded element and clears the ref. Traced with both steps:
- Next clicked on step 1 with empty fields:
stepErrorshas two entries,setShownstores them,focusTargetbecomes'summary', the render draws the summary, the effect focuses it. - Next clicked with a valid email and an 8-plus-character password:
stepErrorsis empty,shownis cleared,setStep(1)runs,focusTargetbecomes'heading', the render shows "Step 2 of 2: Profile", the effect focuses that<h2>.
Three idioms in the code: Partial<Record<keyof Values, string>> is an object whose keys are the field names and whose values are optional strings; as const on the fields arrays makes TypeScript treat them as fixed lists of exact names, so all[f] is checked; and setShown(({ [k]: _gone, ...rest }) => rest) receives the current errors object, pulls out the entry named by k (the unused _gone) and returns a copy of the rest, so one field's error is removed without touching the others.
The test (numbered checks 1 to 4, with 2b, each check one requirement, check 5 is a control; emptyNext renders the form and clicks Next with empty fields):
import '../dom-env.mjs';
import assert from 'node:assert/strict';
import { render, screen, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SignupWizard } from './SignupWizard.tsx';
const user = userEvent.setup();
const active = () => document.activeElement as HTMLElement;
async function emptyNext(manageFocus = true) {
render(<SignupWizard manageFocus={manageFocus} />);
await user.click(screen.getByRole('button', { name: 'Next' }));
}
// 1. Invalid Next: errors listed, focus moves to the summary, fields are wired to their messages.
await emptyNext();
assert.equal(active(), screen.getByTestId('summary'));
assert.ok(active().querySelector('[role=alert]'));
assert.match(active().textContent!, /There are 2 problems/);
const email = screen.getByLabelText('Email');
assert.equal(email.getAttribute('aria-invalid'), 'true');
const msg = document.getElementById(email.getAttribute('aria-describedby')!)!;
assert.equal(msg.textContent, 'Enter an email address like name@example.com.');
console.log('1 invalid Next: focus on the error summary containing role=alert', '| email described by:', msg.textContent);
// 2. A summary link moves focus to the field.
await user.click(screen.getByRole('link', { name: /at least 8/ }));
assert.equal(active(), screen.getByLabelText('Password'));
console.log('2 summary link focuses:', (active() as HTMLInputElement).name);
// 2b. Enter inside a text field submits through the form's Next button.
await user.type(screen.getByLabelText('Email'), '{Enter}');
assert.equal(active(), screen.getByTestId('summary'));
console.log('2b Enter in a field submitted the form: focus on the summary again');
// 3. Fixing a value clears its error; valid Next moves focus to the new step heading.
await user.type(screen.getByLabelText('Email'), 'a@b.co');
assert.equal(screen.getByLabelText('Email').getAttribute('aria-invalid'), null);
await user.type(screen.getByLabelText('Password'), 'longenough');
await user.click(screen.getByRole('button', { name: 'Next' }));
assert.equal(active().tagName, 'H2');
assert.equal(active().textContent, 'Step 2 of 2: Profile');
console.log('3 step 2: focus on <h2>', JSON.stringify(active().textContent));
// 4. Back keeps what was typed.
await user.click(screen.getByRole('button', { name: 'Back' }));
assert.equal((screen.getByLabelText('Email') as HTMLInputElement).value, 'a@b.co');
console.log('4 back: email still', (screen.getByLabelText('Email') as HTMLInputElement).value);
// 5. Control: the same check against a build without focus management must FAIL.
cleanup();
await emptyNext(false);
assert.notEqual(active(), screen.getByTestId('summary'));
console.log('5 control (no focus management): focus stayed on', active().tagName, '(assertion 1 would fail here)');
Run in a node:22 container with node --import tsx wizard/wizard.test.tsx (React 18.3.1, Testing Library 16, jsdom 25), it prints:
1 invalid Next: focus on the error summary containing role=alert | email described by: Enter an email address like name@example.com.
2 summary link focuses: password
2b Enter in a field submitted the form: focus on the summary again
3 step 2: focus on <h2> "Step 2 of 2: Profile"
4 back: email still a@b.co
5 control (no focus management): focus stayed on BUTTON (assertion 1 would fail here)
The process exits 0 only if every assert passes, and check 5 renders the same form with focus management switched off to show that the focus assertion does fail without it. tsc --noEmit on the same files reports no type errors.
What this does not prove, and how to cover it
jsdom checks the DOM wiring (names, descriptions, focus target, order), not what a particular screen reader says. Confirm the spoken result by hand with at least one desktop pairing (for example NVDA, a free Windows screen reader, with Firefox or Chrome, or VoiceOver, the one built into macOS, with Safari) and by tabbing through the form with the mouse unplugged. The manual pass is the essential one. A cheap addition is an automated rules check such as axe-core (an open-source engine that scans a rendered page for accessibility rule violations) in the end-to-end suite, which catches missing labels and invalid ARIA.
Trade-offs and pitfalls
- Inline error vs summary. Inline messages sit next to the problem; the summary gives one place to hear all problems at once after a failed "Next". Use both. The summary alone forces users to remember messages; inline alone leaves a screen reader user on the button with no news.
role="alert"on every inline error makes several messages interrupt each other when a step has multiple failures. Keep the alert on the single summary.- Validating every keystroke announces "invalid" while the user is mid-word. Validate on "Next" and clear errors on the keystroke that fixes them (as the component does); if earlier feedback is wanted, also validate on blur after the first attempt, which this component does not do.
- Removing fields from the DOM between steps loses their values unless the values live in the parent's state, as here, so "Back" restores them.
- Color-only errors fail users who cannot see color; the message text is the signal and the red border is decoration.
- Disabled "Next" until valid hides why the form cannot proceed; leave it enabled and explain.
Running the code
Everything runs from one project root in a node:22 container. Files: dom-env.mjs, package.json, tsconfig.json and wizard/SignupWizard.tsx and wizard/wizard.test.tsx (the component and the test above).
npm i react@18.3.1 react-dom@18.3.1 @testing-library/react@16 @testing-library/dom@10 @testing-library/user-event@14 jsdom@25 tsx typescript@5 @types/react@18 @types/react-dom@18 @types/node@22
node --import tsx wizard/wizard.test.tsx
npx tsc --noEmit
dom-env.mjs (copies the jsdom window's globals onto Node's global object):
import { JSDOM } from 'jsdom';
const { window } = new JSDOM('<!doctype html><body></body>', { url: 'http://localhost/', pretendToBeVisual: true });
for (const k of Object.getOwnPropertyNames(window)) if (!(k in globalThis)) Object.defineProperty(globalThis, k, { configurable: true, get: () => window[k] });
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
package.json:
{ "type": "module" }
tsconfig.json:
{"compilerOptions":{"jsx":"react-jsx","strict":true,"module":"esnext","moduleResolution":"bundler","target":"es2022","allowImportingTsExtensions":true,"noEmit":true,"skipLibCheck":true}}
Without "type": "module" the test does not compile (esbuild, used by tsx, reports Top-level await is currently not supported with the "cjs" output format); without "jsx": "react-jsx" the JSX becomes React.createElement calls and the run stops with ReferenceError: React is not defined.
You must design a robust cache invalidation procedure for frontend deploys that minimizes downtime and avoids serving stale pages across millions of CDN objects. Describe an operational plan that covers asset versioning, cache purges versus immutable assets, staged deployment, and rollback actions.
Sample Answer
Operational plan
1. Asset versioning. Every deploy's static assets (JS, CSS, images) are content-hashed into their
filenames (as in a webpack [contenthash] setup), so a given URL is immutable for the lifetime of the
build and can be cached with a very long max-age. This is the foundation the rest of the plan relies
on: it means the only thing that ever needs active invalidation is the small set of URLs that point at
those hashed assets (mainly the HTML entry point), not the assets themselves.
2. Cache purges versus immutable assets. Because hashed assets never change under their own URL,
they never need to be purged at all, only the non-hashed, frequently-changing entry points (HTML
pages, and any API responses embedding asset references) need active purging or a short TTL. This
distinction matters operationally: a full wildcard purge across millions of CDN objects is slow to
propagate and expensive; needing to purge almost nothing (because almost everything is immutable) is
what keeps a deploy fast.
3. Surrogate keys for staged, partial invalidation. For the smaller set of things that do need
active invalidation, tag each cached object with a logical surrogate key at cache time (for example
build-v42 or page-type:product), a mechanism supported by CDNs like Fastly and Akamai via a
Surrogate-Key response header. Purging then targets a tag, not a URL pattern or a full wildcard, so
you can invalidate exactly "everything from build v41" without touching unrelated content, and without
the latency of a full-catalog purge.
4. Staged deployment. Roll the new build out to a percentage of traffic first (canary) rather than
100% at once, so the new hashed assets and surrogate-key purge both get validated against real traffic
before the old build's traffic share goes to zero. During this window, both the old and new builds'
hashed assets need to remain servable (nothing to purge there, since they're immutable and distinct
URLs), only the HTML/entry-point routing needs to know which build a given user is on.
5. Rollback. Because old builds' hashed assets are never purged during a normal deploy, rolling
back is just re-pointing the entry point (and its surrogate-key purge) back at the previous build's
already-live, already-cached assets, not a race to re-deploy old files before someone's browser tries
to load them. Keep a grace period (a defined number of recent builds' worth of hashed assets) available
rather than aggressively garbage-collecting old assets immediately after a deploy, so a rollback is
always instant rather than depending on assets that may have already been cleaned up.
Putting it together: the plan minimizes downtime because almost nothing needs an active purge (the
immutable-asset strategy), and what does need active invalidation is scoped precisely (surrogate keys)
instead of broad, so a deploy's cache-invalidation footprint is small and fast regardless of how many
millions of unrelated objects are sitting in the CDN.
Given a sequence of numbers, rearrange it into the next lexicographically greater permutation in-place, or into the lowest possible order if none exists (no extra array). Explain the pattern you look for from the right end of the sequence.
Sample Answer
Direct answer
Scan from the right end looking for the first position where the sequence stops being non-increasing, that is, the first index i (from the right) where nums[i] < nums[i+1]. That break point is the last place a small increase is still possible; everything to its right is already at its locally largest (fully descending) arrangement. Swap nums[i] with the smallest value to its right that is still greater than it, then reverse everything after i to put that suffix back into its smallest possible order. If no such break point exists, the whole array is already the highest permutation, and reversing it entirely produces the lowest one.
Structured elaboration
Why look from the right, and why a descending suffix is the signal
A sequence's permutations are ordered lexicographically the same way words are ordered in a dictionary: comparing left to right, the first differing position decides the order. A suffix that is sorted in strictly non-increasing order is, among all permutations of that suffix, already at its maximum, there is no larger arrangement of exactly those values without changing something to its left. So working from the right, the first position where this "already maximal" pattern breaks (nums[i] < nums[i+1]) marks the rightmost digit that can still be bumped up while changing as little as possible.
The three-step recipe
- Find the pivot. Scan right to left for the first
iwithnums[i] < nums[i+1]. - Find its replacement. Among the (non-increasing) suffix starting at
i+1, find the smallest value that is still greater thannums[i], scanning from the right guarantees finding the rightmost such value, which is also the smallest one greater thannums[i]precisely because the suffix is sorted descending. - Swap and reverse. Swap
nums[i]with that value, then reverse the suffix afteri(which is still descending after the swap) to turn it into ascending order, the smallest possible arrangement of those values.
If no pivot exists (the array is fully non-increasing), the whole array is the last permutation in lexicographic order; reversing it wraps around to the first (lowest) one.
Worked example
def next_permutation(nums):
n = len(nums)
i = n - 2
while i >= 0 and nums[i] >= nums[i + 1]:
i -= 1
if i >= 0:
j = n - 1
while nums[j] <= nums[i]:
j -= 1
nums[i], nums[j] = nums[j], nums[i]
left, right = i + 1, n - 1
while left < right:
nums[left], nums[right] = nums[right], nums[left]
left += 1
right -= 1
return nums
print(next_permutation([1, 2, 3])) # ascending, plenty of room to bump up
print(next_permutation([3, 2, 1])) # fully descending, no pivot exists
print(next_permutation([1, 1, 5])) # duplicate values
print(next_permutation([1, 3, 2])) # pivot not at position 0
This prints:
[1, 3, 2]
[1, 2, 3]
[1, 5, 1]
[2, 1, 3]
Tracing [1, 3, 2] by hand: scanning from the right, nums[1]=3 >= nums[2]=2, so i moves to 0; nums[0]=1 < nums[1]=3, so the pivot is i=0. The suffix from index 1 onward is [3, 2] (descending, as expected). Scanning from the right for the first value greater than nums[0]=1 finds nums[2]=2 first (j=2). Swapping gives [2, 3, 1], then reversing the suffix after index 0 ([3, 1] to [1, 3]) gives the final [2, 1, 3], matching the printed output.
Key points
- The pivot search and the replacement search are both right-to-left scans, but over different ranges (the whole array for the pivot, the suffix after the pivot for the replacement).
- Because the suffix is descending, the first value found from the right that exceeds
nums[i]is guaranteed to be the smallest such value, no separate minimum-search is needed. - Reversing (rather than re-sorting) the suffix after the swap works because the suffix, after removing the swapped element, is still descending; reversing a descending sequence yields an ascending one in O(n) time instead of O(nlogn).
Complexity
Time: O(n). Each of the three phases (pivot search, replacement search, reversal) is a single linear scan over at most the whole array, and they never overlap in a way that multiplies the cost.
Space: O(1). All operations are in-place swaps and index bookkeeping.
Edge cases
- Single-element or empty array: the pivot search loop finds no valid
i(or the array is trivially length 0 or 1), and the final reversal is a no-op. - Fully descending array (the highest permutation): no pivot found, the whole array gets reversed into the lowest permutation, as shown with
[3, 2, 1] -> [1, 2, 3]. - Duplicate values: the
nums[j] <= nums[i]andnums[i] >= nums[i+1]comparisons use<=/>=rather than strict inequalities, which correctly treats equal adjacent values as part of the non-increasing suffix and avoids picking a "replacement" that is only equal, not strictly greater.
Trade-offs & pitfalls
A tempting shortcut is generating every permutation (for example, via a library permutation generator) and searching for the one lexicographically after the current arrangement. That works for validation on small inputs but is exponential in the array length and defeats the purpose of an in-place, constant-space algorithm; it is useful only as a brute-force cross-check while testing the real algorithm, not as a solution. The most common implementation bug is using strict < instead of <= in the replacement search, which mishandles duplicate values by either picking an equal (not strictly greater) element or skipping past the correct one.
JSON.stringify returns an empty object for a Map or Set. Explain why, and design a way to serialise and restore data that contains them, including nested ones.
Sample Answer
Direct answer
JSON.stringify only visits an object's own enumerable string-keyed properties, and a Map or Set has none: its entries live in an internal slot (hidden storage inside the object) that property enumeration cannot see, so both serialize as {} (MDN states this). To round-trip them, convert them to an ordinary JSON shape the other side can recognise: a tagged object such as {"$type":"Map","entries":[[key, value], ...]} produced by a replacer function passed to JSON.stringify, and rebuilt by a reviver function passed to JSON.parse. Nested values work because both callbacks are called for every value in the tree.
Terms: a replacer is a function (key, value) that JSON.stringify calls for each value and whose return value is what gets serialized (inside it, this is the object holding the key). A reviver is the matching function for JSON.parse, called after parsing each value, deepest values first. A tag is a marker field (here $type) that tells the reviver what to rebuild.
Design: encode, decode, and the traps
- Entries, not objects. A
Mapbecomes an array of[key, value]pairs, not a plain object, because Map keys may be non-strings (numbers, objects) and an object would stringify them all. ASetbecomes an array of values. - Nesting for free. After the replacer returns the tagged object,
JSON.stringifyvisits its contents, so aMapinside aMapinside aSetis tagged at each level. The reviver runs bottom-up, so by the time it sees an outer{"$type":"Map"}, the inner ones are already rebuilt. - Values JSON cannot hold.
BigIntthrows aTypeErrorinJSON.stringify(MDN), even if a replacer returns one, so it is tagged as a string of digits. ADatehas atoJSONmethod that runs before the replacer, so the replacer receives a string; read the original throughthis[key]. - Symbol keys. MDN: symbol-keyed properties are ignored, even with a replacer, so they cannot be handled key by key. The scheme detects a plain object that has enumerable symbol keys and re-emits it as a tagged
Objwith its string entries and its symbol entries as[name, value]pairs. Only registered symbols (created withSymbol.for(name)) can be restored with the same identity, becauseSymbol.keyForrecovers the name andSymbol.forreturns the same symbol. A uniqueSymbol("local")cannot come back, so the encoder throws rather than silently dropping data. - Tag collisions. If real user data already has a
$typekey, a naive reviver would misread it. The encoder therefore also wraps any plain object that owns a$typekey into the taggedObjform (entries as pairs, so no raw$typekey remains in the output), and the reviver leaves unknown tags alone. - Trust. The decoder only builds built-in types from fixed tags, never evaluates code or looks up constructors by name from the input.
Working implementation
import assert from "node:assert/strict";
const isPlain = (v) =>
v !== null && typeof v === "object" && Object.getPrototypeOf(v) === Object.prototype;
// replacer: called for every value, depth first, with `this` = the holder object.
function replacer(key, value) {
const raw = this[key]; // `value` is already toJSON()-ed; Date, for example, is a string by now
if (raw instanceof Date) return { $type: "Date", iso: raw.toISOString() };
if (value instanceof Map) return { $type: "Map", entries: [...value] };
if (value instanceof Set) return { $type: "Set", values: [...value] };
if (typeof value === "bigint") return { $type: "BigInt", digits: value.toString() };
if (isPlain(value)) {
const symbols = Object.getOwnPropertySymbols(value).filter((s) =>
Object.prototype.propertyIsEnumerable.call(value, s));
const hasTagKey = Object.hasOwn(value, "$type");
if (symbols.length || hasTagKey) {
return {
$type: "Obj",
entries: Object.entries(value),
symbols: symbols.map((s) => {
const name = Symbol.keyFor(s);
if (name === undefined) throw new TypeError("cannot serialise non-registered symbol key " + String(s));
return [name, value[s]];
}),
};
}
}
return value;
}
// reviver: called bottom-up, so nested tagged values are already rebuilt.
function reviver(key, value) {
if (value === null || typeof value !== "object" || typeof value.$type !== "string") return value;
switch (value.$type) {
case "Date": return new Date(value.iso);
case "Map": return new Map(value.entries);
case "Set": return new Set(value.values);
case "BigInt": return BigInt(value.digits);
case "Obj": {
const obj = Object.fromEntries(value.entries);
for (const [name, v] of value.symbols) obj[Symbol.for(name)] = v;
return obj;
}
default: return value;
}
}
export const encode = (data) => JSON.stringify(data, replacer);
export const decode = (text) => JSON.parse(text, reviver);
// --- demo ---
console.log("plain JSON of a Map:", JSON.stringify(new Map([["a", 1]])), "| Set:", JSON.stringify(new Set([1, 2])));
const id = Symbol.for("app.id");
const original = {
user: "ada",
roles: new Set(["admin", "dev"]),
scores: new Map([
["math", new Map([["q1", 90], ["q2", 85]])], // Map nested in a Map
[{ week: 3 }, new Set([1n, 2n])], // object key, Set of BigInt
]),
seen: new Date("2026-01-02T03:04:05.000Z"),
meta: { [id]: 42, $type: "user-supplied value" }, // symbol key and a key that collides with our tag
note: { $type: "Map", entries: [["fake", 1]] }, // user data that merely looks like our Map tag
};
const text = encode(original);
console.log(text);
const restored = decode(text);
assert.deepStrictEqual(restored, original);
console.log("round trip equal:", true);
console.log("restored symbol key:", restored.meta[id], "| nested Map:", restored.scores.get("math").get("q2"));
try {
encode({ [Symbol("local")]: 1 });
} catch (e) {
console.log(e.name + ": " + e.message);
}
Run in node:22, it prints:
plain JSON of a Map: {} | Set: {}
{"user":"ada","roles":{"$type":"Set","values":["admin","dev"]},"scores":{"$type":"Map","entries":[["math",{"$type":"Map","entries":[["q1",90],["q2",85]]}],[{"week":3},{"$type":"Set","values":[{"$type":"BigInt","digits":"1"},{"$type":"BigInt","digits":"2"}]}]]},"seen":{"$type":"Date","iso":"2026-01-02T03:04:05.000Z"},"meta":{"$type":"Obj","entries":[["$type","user-supplied value"]],"symbols":[["app.id",42]]},"note":{"$type":"Obj","entries":[["$type","Map"],["entries",[["fake",1]]]],"symbols":[]}}
round trip equal: true
restored symbol key: 42 | nested Map: 85
TypeError: cannot serialise non-registered symbol key Symbol(local)
Reading the long encoded line from left to right:
"user":"ada"is an ordinary string and passes through the replacer unchanged."roles":{"$type":"Set","values":["admin","dev"]}is theSet, replaced by a tagged object holding its values as an array."scores":{"$type":"Map","entries":[...]}is the outerMap. Itsentriesarray holds two[key, value]pairs. The first pair is["math", {"$type":"Map","entries":[["q1",90],["q2",85]]}]: the innerMapis tagged again because the replacer is called on it too.- The second pair is
[{"week":3},{"$type":"Set","values":[...]}]. Its key{"week":3}is an ordinary object written out as JSON, and its value is aSetwhose twoBigIntmembers (1nand2nare BigInt literals, whole numbers of any size) each became{"$type":"BigInt","digits":"1"}and{"$type":"BigInt","digits":"2"}, because plain JSON cannot hold a BigInt. "seen":{"$type":"Date","iso":"2026-01-02T03:04:05.000Z"}is theDate, taken fromthis[key]before itstoJSONstring replaced it."meta":{"$type":"Obj","entries":[["$type","user-supplied value"]],"symbols":[["app.id",42]]}is the plain object that had two special features. Its own$typekey moved intoentries, so the only raw$typein that object is the tag the encoder wrote, and its symbol keySymbol.for("app.id")was written as the pair["app.id", 42]undersymbols."note":{"$type":"Obj","entries":[["$type","Map"],["entries",[["fake",1]]]],"symbols":[]}is user data that only looks like aMaptag and has no symbol keys. The$typerule alone wrapped it, so the reviver restores it as the plain object it was instead of building aMap.
The assertion assert.deepStrictEqual(restored, original) compares the Maps, Sets, Date, BigInt values, symbol-keyed property and the look-alike note object deeply and passes. Replacing the $type test with const hasTagKey = false; makes this assertion throw an AssertionError, because note then comes back as a real Map. The object key { week: 3 } is round-tripped as an equal object, not the same object: JSON cannot preserve identity (two keys that were the same reference come back as two copies), so Maps keyed by object identity do not survive in the strict sense.
Alternatives and trade-offs
| Option | Use when | Limit |
|---|---|---|
| Tagged replacer/reviver (above) | Text must be JSON: HTTP bodies, localStorage, logs, files | Every consumer must know the tags; cycles still throw |
Convert at the edge to plain arrays/objects ([...map], Object.fromEntries) | Format is a public API contract | Rebuild by hand at each call site; no automatic nesting |
structuredClone / postMessage | Copy in memory or between workers and tabs | Not text, so it cannot be stored in JSON or sent over HTTP |
Define toJSON on your own classes | Types you own | Does not help for built-in Map and Set unless you subclass or patch prototypes (avoid patching) |
Pitfalls: a Map with duplicate-looking keys (1 and "1") stays distinct as entries but would collide in an object; large payloads are walked twice (once by the replacer, once by JSON.stringify); version the format (a "v" field) so tags can change safely; a reviver returning undefined deletes the property.
Running the code
Save the listing as codec.mjs (an ES module, no install needed).
docker run --rm --ulimit core=0 -v "$PWD":/w -w /w node:22 sh -c 'timeout 100 node codec.mjs'
Legal sign-off is going to take three weeks, but the team wants to ship in one. How do you manage that timeline without steamrolling legal's concerns?
Sample Answer
Direct answer
Treat "legal needs three weeks but the team wants one week" as a scope problem, not a speed problem. Split the release into what can ship without new legal review and what genuinely needs sign-off, then give legal a narrow, well-defined ask for the second piece instead of asking them to review everything faster. The team ships on time, and the risky piece launches on its own review-driven schedule.
Structured elaboration
Find out what is actually blocking legal
"Legal sign-off" is rarely one undivided review. Ask legal directly which specific elements are new or unreviewed, and which are unchanged from something already approved. Most releases are a mix, and the review clock usually belongs to a small fraction of the surface area.
Split the release along that line
Everything that reuses already-approved language, patterns, or flows ships in the one-week window. Anything net-new that legal has not seen goes behind a feature flag (a toggle that keeps new code hidden from users until you're ready to turn it on) and ships later, once sign-off lands, decoupled from the original deadline.
Reduce legal's per-item cost, do not just ask for speed
A vague "please review this flow" invites a slow, open-ended read. A redlined diff (a side-by-side markup showing exactly which words changed from the last approved version, like tracked changes) against previously-approved language, with a one-paragraph explanation of what changed and why, is something legal can turn around fast because the review surface is small and explicit.
Keep everyone honest about the split
Do not quietly ship around legal's concern and call it done. Tell legal what you are shipping now, what is gated, and why you drew the line there, and let them confirm or push back on the boundary itself, not just react to a missed deadline.
Worked example
A signup redesign is due in one week. It includes a new consent checkbox asking users to opt into sharing data with a third-party analytics partner, and the copy for that checkbox has never been reviewed (legal quotes three weeks because it touches data-sharing language that needs a compliance read). Everything else in the redesign, the new layout and the reworked field order, is unchanged from an already-approved pattern used elsewhere in the product.
The split: ship the redesign now using the existing, already-approved consent copy and opt-in behavior unchanged. Put the new third-party-sharing consent language and checkbox behind a flag, off by default. Send legal a one-page diff: exactly the new sentence, what data it covers, and why it is being added, instead of the whole signup flow. The redesign ships in the one-week window. The new consent copy ships later, whenever legal actually signs off, on its own timeline, without ever having blocked the rest of the release.
Trade-offs and pitfalls
A flag-gated split adds real overhead: someone has to remember to remove the flag, and a half-shipped feature can linger longer than planned if nobody owns closing the loop. It also only works when the risky piece is genuinely separable. If the new element is load-bearing, meaning the whole flow depends on it, forcing a split creates a worse product than waiting.
The biggest pitfall is doing the split unilaterally and only telling legal afterward. That reads as shipping around the reviewer even when the intent was reasonable, and it burns the relationship needed for the next time this happens. The senior move is proposing the boundary and getting legal's explicit agreement on it before the ship date, not after.
Implement a reusable debounce utility in JavaScript with options for leading and trailing invocation and a cancel method. Ensure it correctly handles rapid successive calls, retaining 'this' context, and returns the result of the debounced function when appropriate. Provide an ES6 implementation and explain edge cases (e.g., cancel during pending leading call).
Sample Answer
Direct answer
A correct debounce utility needs to track not just a pending timer but how many calls occurred within the current window, because leading-edge and trailing-edge invocation interact: a single call in a leading+trailing window must fire once, not twice, while multiple calls in that same window correctly fire twice (once on the leading edge with the first call's arguments, once on the trailing edge with the last call's arguments). cancel() can only suppress a pending trailing call; it cannot undo a leading call that has already executed synchronously.
Structured elaboration
- Leading vs. trailing semantics: leading invokes immediately on the first call of a new window; trailing invokes once the window elapses with no further calls. Both can be enabled together, which is where most naive implementations get the single-call case wrong (see below).
- The double-invoke trap: a naive implementation that unconditionally fires the trailing call whenever
trailingis true will invoke twice for a window containing exactly one call under{leading: true, trailing: true}, once from the leading edge and once from the trailing edge, for the same logical call. The fix is tracking a per-window call count and suppressing the trailing invocation specifically when the window had exactly one call AND that call already fired on the leading edge. - Retaining
thiscontext: use a regularfunctionexpression for the returned debounced wrapper (not an arrow function) and call the wrapped function withfn.apply(thisArg, args), capturingthisfrom the call site of the debounced wrapper itself, not from wheredebounce()was originally invoked. - Cancel during a pending leading call: if
leading: trueand a call has already fired synchronously, the side effect has already happened, cancel cannot retroactively undo it. Whatcancel()legitimately does is clear the pending timer so the TRAILING call that would otherwise fire later never happens. This distinction (already-happened vs. still-pending) is the actual edge case named in the question, and a test must assert on it precisely: the leading call's effect persists after cancel, only the trailing call is suppressed. - Return value: the debounced function should return whatever the underlying function returned on its most recent actual invocation, since a caller synchronously reading the return value on a leading call needs that value immediately, not after the debounce window closes.
Worked example (executed, ES6/ECMAScript 2015)
function debounce(fn, wait, { leading = false, trailing = true } = {}) {
let timer = null, lastArgs = null, lastThis = null, result, callCountInWindow = 0;
function invoke(args, thisArg) { result = fn.apply(thisArg, args); return result; }
function debounced(...args) {
const isNewWindow = timer === null;
lastArgs = args; lastThis = this; callCountInWindow += 1;
if (isNewWindow && leading) invoke(args, this);
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => {
const shouldTrailingInvoke = trailing && !(leading && callCountInWindow === 1);
if (shouldTrailingInvoke) invoke(lastArgs, lastThis);
timer = null; callCountInWindow = 0;
}, wait);
return result;
}
debounced.cancel = function () {
if (timer !== null) { clearTimeout(timer); timer = null; callCountInWindow = 0; }
};
return debounced;
}
Run against seven scenarios with real timers (30ms wait, real setTimeout, no mocked clock):
scenario1_trailing_only: ["c"]
scenario2_leading_and_trailing_multi_call: ["a","c"]
scenario3_leading_and_trailing_single_call: ["only"]
scenario4_this_context: fn ran without throwing, retained call succeeded
scenario5_leading_return_value: 10
scenario6_cancel_during_pending_leading: {"callsRightAfterLeadingEdge":["first"],"callsAfterCancelAndWait":["first"]}
scenario7_cancel_noop: no throw, safe
Scenario 2 confirms the multi-call case correctly fires twice (["a","c"], first and last), scenario 3 confirms the single-call case correctly fires exactly once (not ["only","only"]), and scenario 6 confirms the cancel-during-pending-leading-call edge case precisely: first is recorded once (the leading call already ran) and stays at exactly ["first"] after cancel and the wait elapses, meaning the trailing call for second was successfully suppressed and the already-fired leading call was correctly left alone.
Trade-offs and pitfalls
The most common wrong turn is the double-invoke bug described above, which passes any test that only checks "trailing mode alone" and "leading mode alone" separately and never tests the combination on a single-call window. A second pitfall is using an arrow function for the debounced wrapper, which permanently binds this to the enclosing lexical scope at definition time and silently breaks any usage as an object method. A third is assuming cancel() should reset callCountInWindow retroactively affect a leading call that already fired; it should not, and a test asserting the leading call's side effect persists after cancel is the one that actually catches an implementation that got this wrong.
Want to create your own tailored preparation guide using our deep research?
Get Started for FreeInterview-Ready Courses
Visual-first, interactive, structured learning paths
Browse Frontend Developer jobs
AI-enriched listings across hundreds of company career pages
Explore Jobs