Spotify Frontend Developer Interview Questions & Prep Guide | InterviewStack.io
`;\n\nconst browser = await chromium.launch({ args: ['--js-flags=--expose-gc'] });\nconst page = await browser.newPage();\nawait page.setContent(html);\nconst names = ['asWritten', 'heldByRegistry', 'heldByDocumentListener', 'heldByTimer', 'heldByObserver', 'fixedHandle'];\nconst collected = await page.evaluate((n) => window.run(n), names);\nconsole.log('collected after el.remove(): ', JSON.stringify(collected));\nassert.deepEqual(collected, { asWritten: true, heldByRegistry: false, heldByDocumentListener: false, heldByTimer: false, heldByObserver: false, fixedHandle: true });\n\nconst released = await page.evaluate(() => window.release());\nconsole.log('after clearing each outside reference: ', JSON.stringify(released));\nassert.ok(Object.values(released).every(Boolean));\nawait browser.close();\n\nHow the harness reads: cases holds six functions. Each builds one widget a different way, removes its element, and keeps only a WeakRef to it. run calls the chosen cases, waits 50 ms, then calls gc() three times with 20 ms pauses. The first wait matters because a WeakRef target stays alive until the end of the task that created it, so the collection has to happen in a later task; repeating gc() gives the engine more than one chance to finish. refs[n].deref() === undefined is therefore true exactly when widget n was collected. release clears the registry, aborts the listener, clears the timer and disconnects the observer, then collects again.\n\nOutput:\n\ncollected after el.remove(): {\"asWritten\":true,\"heldByRegistry\":false,\"heldByDocumentListener\":false,\"heldByTimer\":false,\"heldByObserver\":false,\"fixedHandle\":true}\nafter clearing each outside reference: {\"asWritten\":true,\"heldByRegistry\":true,\"heldByDocumentListener\":true,\"heldByTimer\":true,\"heldByObserver\":true,\"fixedHandle\":true}\n\nIn the first line true means collected and false means still alive after removal. asWritten is the question's snippet, unchanged, and it is collected. The four held... widgets are retained until their outside reference is released, and fixedHandle is collected right after destroy(). The second line is all true because releasing every outside reference let the collector reclaim all six elements.\n\nPitfalls\n\nDo not add cleanup of listeners on the removed element itself out of habit. It costs code and hides the real holders.\nel.remove() is not destruction. It only detaches; whatever else points at the element decides whether it lives.\nSmall leaks multiply. One leaked widget with 10 child nodes is invisible; a list that re-renders 500 times a session leaks thousands of nodes.\nCollections hide leaks from reviewers. A Map of \"active widgets\" with no removal path is the most common real cause.\nComplexity: creation and destruction are O(1) per widget plus its subtree; the leak cost is O(widgets created x subtree size) of retained memory.\n\nRunning the code\n\nmkdir widget && cd widget # save test-widget.mjs here\necho '{\"type\":\"module\"}' > package.json\nnpm i playwright@1.48.2 && npx playwright install chromium\nnode test-widget.mjs\n\nRun in the mcr.microsoft.com/playwright:v1.48.2-jammy container (Node 20), where Chromium is already installed."}},{"@type":"Question","name":"Explain the BEM naming methodology and how it helps maintainable styles. Given this component markup, propose BEM-style class names and a simple CSS example for an input group with help text and an error state:\n
\n\nShow BEM classes and a modifier for an error state.","acceptedAnswer":{"@type":"Answer","text":"Direct answer\n\nBEM (Block, Element, Modifier) is a class-naming convention. A block is a standalone component (input-group), an element is a part that only makes sense inside its block (input-group__help, joined by two underscores), and a modifier is a variation or state of a block or element (input-group__help--error, joined by two hyphens). Every selector is a single class, so specificity (the ranking the cascade uses when two rules compete) is the same everywhere and a rule's meaning does not depend on where the markup sits. That makes styles easy to search, safe to move, and hard to break by accident.\n\nHow BEM helps maintainability\n\nFlat specificity. .input-group__input and .input-group__input--error both score (0,1,0): zero id selectors, one class, zero type selectors. Nothing needs !important or a longer selector to win; ordering in the file decides ties.\nNo dependence on DOM structure. .input-group__help works whether the paragraph is a child, a grandchild, or moved; compare .input-group > p, which breaks when someone wraps it.\nNamespacing by block name. A generic .help or .error in one feature will collide with another's. Prefixing with the block makes collisions unlikely without any tooling (it is still a convention, so nothing enforces it).\nGreppable and deletable. Searching input-group finds every rule and every use; deleting a component means deleting one block's rules.\nElements do not nest in names. Write card__title, never card__header__title; the element belongs to the block, not to another element.\n\nThe example: input group with help text and an error state\n\nThe original markup has two accessibility gaps worth fixing while adding classes: the is not connected to the input, and the help text is not tied to it. Connect them with for/id and aria-describedby (an attribute that lists the id of the element whose text a screen reader, one kind of assistive technology, should read as the control's description). inputmode=\"decimal\" asks phones to show a numeric keypad with a decimal point while the field stays a plain text input.\n\n\n\n/ Block: input-group. Elements: label, input, help. Modifier: error (set on each part that changes). /\n.input-group { display: grid; gap: 4px; max-width: 20rem }\n.input-group__label { font-weight: 600 }\n.input-group__input { padding: 8px; border: 2px solid rgb(118, 118, 118); border-radius: 4px }\n.input-group__help { margin: 0; font-size: 0.875rem; color: rgb(85, 85, 85) }\n\n/ Modifiers come AFTER the base rules: same specificity (0,1,0), so source order decides /\n.input-group__input--error { border-color: rgb(179, 38, 30) }\n.input-group__help--error { color: rgb(179, 38, 30) }\n\n/* A second block, btn, shows how a modifier and a native state compete for the same property.\n .btn--primary sets opacity: 1 on purpose, so it conflicts with the :disabled rule below. */\n.btn { display: inline-flex; align-items: center; padding: 8px 16px; border: 0; background: rgb(11, 92, 173); color: #fff; opacity: 1 }\n.btn__icon { width: 1em; height: 1em; margin-right: 8px }\n.btn--primary { opacity: 1 } / modifier, (0,1,0) /\n.btn:disabled { opacity: 0.5; cursor: not-allowed } / native state, (0,2,0): beats the modifier whatever the order /\n\nWhen validation fails, the code adds the modifiers and swaps the text:\n\n \nError: enter a number such as 12.50
\n\nChoices to state out loud:\n\nA modifier is added next to the base class, never instead of it (class=\"input-group__input input-group__input--error\"), so the modifier only overrides what changes.\nModifier on each part vs on the block. Putting input-group--error on the wrapper and writing .input-group--error .input-group__help { ... } also works and needs one class toggle, but the selector scores (0,2,0) and depends on structure. The flat form above keeps every rule at (0,1,0) at the cost of toggling two classes. Pick one per codebase.\nColour is not the only signal. The text itself changes (\"Error: ...\"), and aria-invalid=\"true\" records the state for assistive technology (software such as screen readers that reads the page aloud or in braille). MDN defines it as indicating \"the entered value does not conform to the format expected by the application\" and recommends pairing it with styling and messaging. MDN also suggests styling with the [aria-invalid=\"true\"] attribute selector, which keeps the style and the accessibility state in sync. On its own, an attribute selector scores (0,1,0), the same as one class. Combined with the element's class, .input-group__input[aria-invalid=\"true\"] scores (0,2,0): one class plus one attribute.\nNative states are not modifiers. For the button, use :disabled rather than .btn--disabled, because disabled is a built-in state of the element rather than a style variation. .btn:disabled scores (0,2,0) (one class plus one pseudo-class, a keyword such as :disabled that selects an element in a particular state), so it beats .btn--primary (0,1,0) whatever the order in the file.\nIcon button. The icon is an element (btn__icon) and a look such as btn--primary is a modifier on the block; the icon is aria-hidden because the button text already names the control.\n\nCheck in three engines\n\nimport { chromium, firefox, webkit } from 'playwright';\nimport assert from 'node:assert/strict';\nimport fs from 'node:fs';\n\nconst css = fs.readFileSync('bem.css', 'utf8');\nconst html = ` \n\n\n Save \nSave `;\n\n// what the app does when validation fails: swap text, add the modifiers, set aria-invalid\nconst markInvalid = () => {\n const input = document.getElementById('amount'), help = document.getElementById('amount-help');\n input.classList.add('input-group__input--error'); input.setAttribute('aria-invalid', 'true');\n help.classList.add('input-group__help--error'); help.textContent = 'Error: enter a number such as 12.50';\n};\nconst read = () => {\n const cs = id => getComputedStyle(document.getElementById(id));\n return { border: cs('amount').borderTopColor, help: cs('amount-help').color,\n text: document.getElementById('amount-help').textContent,\n off: cs('off').opacity, offCursor: cs('off').cursor,\n iconGap: getComputedStyle(document.querySelector('.btn__icon')).marginRight };\n};\n\nfor (const [name, type] of [['chromium', chromium], ['firefox', firefox], ['webkit', webkit]]) {\n const browser = await type.launch();\n const page = await browser.newPage();\n await page.setContent(html);\n const before = await page.evaluate(read);\n assert.equal(await page.getByRole('textbox', { name: 'Amount' }).count(), 1); // label is associated with the input\n await page.evaluate(markInvalid);\n const after = await page.evaluate(read);\n console.log(name.padEnd(8), JSON.stringify({ before: [before.border, before.help], after: [after.border, after.help, after.text], off: [after.off, after.offCursor], iconGap: after.iconGap }));\n assert.equal(before.border, 'rgb(118, 118, 118)');\n assert.equal(before.help, 'rgb(85, 85, 85)');\n assert.equal(after.border, 'rgb(179, 38, 30)');\n assert.equal(after.help, 'rgb(179, 38, 30)');\n assert.equal(after.off, '0.5'); // :disabled (0,2,0) beats .btn--primary (0,1,0), even when that rule comes later\n assert.equal(after.offCursor, 'not-allowed');\n assert.equal(after.iconGap, '8px');\n assert.equal(await page.getAttribute('#amount', 'aria-invalid'), 'true');\n await browser.close();\n}\nconsole.log('all assertions passed');\n\nRun in the Playwright 1.48.2 image (Chromium, Firefox, Playwright's WebKit build), all three printed the same values; Chromium shown:\n\nchromium {\"before\":[\"rgb(118, 118, 118)\",\"rgb(85, 85, 85)\"],\"after\":[\"rgb(179, 38, 30)\",\"rgb(179, 38, 30)\",\"Error: enter a number such as 12.50\"],\"off\":[\"0.5\",\"not-allowed\"],\"iconGap\":\"8px\"}\nall assertions passed\n\nMoving the two modifier rules above the base rules makes the border and help colours stay grey and fails the assertion: at equal specificity the later rule wins, so modifiers must follow their base. The second \n \n `;\nconst SNAP = ``;\nconst html = (critical, mode) => ${head(critical, mode)}${BODY}${SNAP};\n\nconst browser = await chromium.launch();\nconst fail = [];\nconst check = (ok, msg) => { console.log((ok ? 'PASS ' : 'FAIL ') + msg); if (!ok) fail.push(msg); };\n\nasync function open(size, page_html) {\n const ctx = await browser.newContext({ viewport: size }); const page = await ctx.newPage();\n await page.route('http://site.test/**', async (route) => {\n const p = new URL(route.request().url()).pathname;\n if (p === '/site.css') { await new Promise((r) => setTimeout(r, CSS_DELAY)); return route.fulfill({ contentType: 'text/css', body: CSS }); }\n route.fulfill({ contentType: 'text/html', body: page_html });\n });\n await page.goto('http://site.test/page', { waitUntil: 'commit' });\n return { ctx, page };\n}\nconst SIZES = { phone: { width: 375, height: 667 }, desktop: { width: 1280, height: 800 } };\n\n// 1. Extract: run the full page at each size and union the matching rules.\nconst keyset = {};\nfor (const [name, size] of Object.entries(SIZES)) {\n const { ctx, page } = await open(size, html('', 'external'));\n await page.waitForLoadState('load');\n keyset[name] = await page.evaluate((${KEYS_FN})());\n await ctx.close();\n}\nconst union = [...new Set([...keyset.phone, ...keyset.desktop])];\nconst buildCss = async (keys) => { const { ctx, page } = await open(SIZES.desktop, html('', 'external')); await page.waitForLoadState('load');\n const css = await page.evaluate((${BUILD_FN})(${JSON.stringify(keys)})); await ctx.close(); return css; };\nconst criticalAll = BREAK === 'empty' ? '' : await buildCss(union);\nconst criticalDesktopOnly = await buildCss(keyset.desktop);\nconst gz = (s) => gzipSync(s).length;\nconsole.log(full css: ${CSS.length} bytes (${gz(CSS)} gzip); critical css: ${criticalAll.length} bytes (${gz(criticalAll)} gzip); rules kept: ${union.length});\n\n// 2. Measure first contentful paint and what the first paint looks like, per size.\nfor (const [name, size] of Object.entries(SIZES)) {\n const run = async (critical, mode) => {\n const { ctx, page } = await open(size, html(critical, mode));\n await page.waitForFunction(() => window.atPaint); // the inline script has run\n const first = await page.evaluate(() => window.atPaint); // FCP time and styles at that moment\n if (mode !== 'external') await page.waitForFunction(() => window.restLoaded === 1, null, { timeout: 15000 }).catch(() => {});\n else await page.waitForLoadState('load');\n await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));\n const final = await page.evaluate(() => window.snap()); await ctx.close();\n return { fcp: Math.round(first.fcp), same: JSON.stringify(first.styles) === JSON.stringify(final) };\n };\n const ext = await run('', 'external'), inl = await run(criticalAll, 'inline');\n console.log(name.padEnd(8), 'FCP external =', ext.fcp, 'ms FCP inline critical =', inl.fcp, 'ms');\n check(ext.fcp >= CSS_DELAY, ${name}: with a blocking stylesheet the first paint waits for the ${CSS_DELAY} ms download);\n check(inl.fcp < CSS_DELAY, ${name}: with critical CSS inlined the first paint does not wait for the stylesheet);\n check(inl.same, ${name}: what is painted first already has its final styles and size);\n}\n\n// 3. A critical set taken only at desktop size is wrong on a phone.\nconst dOnly = await (async () => {\n const { ctx, page } = await open(SIZES.phone, html(criticalDesktopOnly, 'inline'));\n await page.waitForFunction(() => window.atPaint);\n const first = await page.evaluate(() => window.atPaint);\n await page.waitForFunction(() => window.restLoaded === 1); await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));\n const final = await page.evaluate(() => window.snap()); await ctx.close();\n return JSON.stringify(first.styles) !== JSON.stringify(final);\n})();\ncheck(dOnly, 'a critical set extracted only at desktop width changes appearance on a phone when the rest arrives');\n\n// 4. The cost: the inline bytes ride along on every HTML response, a cached stylesheet does not.\nconst htmlBytes = (c, m) => Buffer.byteLength(html(c, m));\nconst views = 3;\nconst externalTotal = htmlBytes('', 'external') * views + CSS.length; // CSS downloaded once, then cached\nconst inlineTotal = htmlBytes(criticalAll, 'inline') * views + CSS.length; // critical repeated in each HTML, rest cached\nconsole.log(bytes over ${views} page views: external = ${externalTotal}, inline critical + cached rest = ${inlineTotal}, extra = ${inlineTotal - externalTotal});\ncheck(inlineTotal - externalTotal >= criticalAll.length * views, 'inlining costs at least the critical CSS size on every page view');\n\nawait browser.close();\nprocess.exit(fail.length ? 1 : 0);\n\nRun in the Playwright 1.48.2 container (Chromium, headless), one sample run prints:\n\nfull css: 3884 bytes (837 gzip); critical css: 1193 bytes (544 gzip); rules kept: 19\nphone FCP external = 629 ms FCP inline critical = 31 ms\nPASS phone: with a blocking stylesheet the first paint waits for the 600 ms download\nPASS phone: with critical CSS inlined the first paint does not wait for the stylesheet\nPASS phone: what is painted first already has its final styles and size\ndesktop FCP external = 621 ms FCP inline critical = 28 ms\nPASS desktop: with a blocking stylesheet the first paint waits for the 600 ms download\nPASS desktop: with critical CSS inlined the first paint does not wait for the stylesheet\nPASS desktop: what is painted first already has its final styles and size\nPASS a critical set extracted only at desktop width changes appearance on a phone when the rest arrives\nbytes over 3 page views: external = 9386, inline critical + cached rest = 13427, extra = 4041\nPASS inlining costs at least the critical CSS size on every page view\nexit=0\n\nHow the extraction code works: KEYS_FN runs inside the page at one screen size. It walks the top-level rules of the stylesheet (document.styleSheets[0].cssRules; each CSSRule has a selectorText such as .hero h1, .lead). For each rule it splits the selector list at commas, removes pseudo-classes such as :hover that cannot be looked up on their own, and asks querySelectorAll for the matching elements; the rule is kept if any match is visible, meaning it has a non-zero box that starts above the bottom edge of the viewport (an element hidden with display: none is judged by its nearest displayed ancestor). For @media rules, it first checks with matchMedia that the condition applies at this screen size, then does the same for the rules inside. It returns the positions of the kept rules as keys such as 3 or 7.2 (rule 2 inside the media rule at position 7). BUILD_FN turns a list of keys back into CSS text, re-wrapping kept media rules in @media ... { }. The main script runs KEYS_FN at phone and desktop size and builds the critical CSS from the union of the keys. snap records computed display, font size, colours, padding and height for every visible element, so the test can compare the first paint with the final page.\n\nReading the sample: the blocking page's first paint arrives just after the 600 ms the stylesheet takes, while the inline page paints in a few tens of milliseconds because nothing it needs is still in flight. The check \"what is painted first already has its final styles and size\" compares computed style and height of every element in the first screen at the moment of the first paint against the same elements after the full stylesheet has loaded: they are identical, so nothing jumps when the rest arrives. The critical slice is 1,193 of 3,884 bytes (19 rules); the rest of the file holds components that never appear on this page's first screen.\n\nExtracting it for a real site\n\n1. Per page template, not per URL. A template is one page layout that many URLs share (every product page uses the same product template). Run the extraction for each distinct layout (home, article, product), against a representative page, in the build or CI.\n2. At several screen sizes, and union the results. The demo extracts at 375 x 667 and 1280 x 800 and keeps a rule if either size needs it. The test proves why: a set taken at desktop size only changed the phone's appearance when the full stylesheet arrived. Tools such as Critical document the same need: it \"supports extracting critical CSS for multiple screen resolutions\". web.dev also lists criticalCSS and Penthouse.\n3. Count hidden elements. An extractor that skips elements with display: none drops the phone rule .nav nav { display: none }, and the first paint then shows a navigation that the final CSS hides (the first-paint style check fails in exactly that case). The extractor above judges a hidden element by its nearest visible ancestor, so a rule that hides something in the first screen stays in the critical set.\n4. Keep @font-face (the rule that declares a web font and where to download it) and the variables the first screen uses (the :root rule is kept above because it matches the page's root element). If the first screen uses a web font, preload the font file it needs and keep its @font-face rule in the slice.\n5. Handle content that appears later. Elements added by JavaScript, cookie banners and A/B variants are invisible to a snapshot taken from static HTML. Extract from a page in the state users first see, or add their rules to a force-include list.\n6. Load the rest without blocking. The pattern in the demo follows web.dev: \"link rel=\"preload\" as=\"style\" requests the style sheet asynchronously\" and the onload attribute \"lets the browser process the CSS when the style sheet finishes loading\", with as the fallback for visitors without JavaScript. Keep the full file under a content-hashed name (the file name includes a fingerprint of its contents, such as site.3fa9c1.css, so a new deploy gets a new name) so the browser can keep it with a long Cache-Control: max-age and never re-download an unchanged file.\n7. Gate it in CI. Re-run the extraction on every build, and fail the build if the critical set exceeds a size budget or if the first-paint style check above fails.\n\nDownsides, with numbers from the same run\n\nCost | What happens | Measured or derived in the run\n\nRepeated bytes | the inlined CSS is part of every HTML response and \"prevents the browser from caching the CSS for reuse on subsequent page loads\" (web.dev) | Over 3 page views, the external stylesheet design transferred 9,386 bytes of HTML plus CSS and the inline design 13,427, 4,041 more: the page overhead is paid each view while the stylesheet is cached\nSize ceiling | web.dev's guidance is to \"keep above-the-fold content under 14 KB (compressed)\" | the demo's critical CSS is 544 bytes compressed (gzip)\nDelayed HTML | inlining a large amount \"delays the transmission of the rest of the HTML document\" | grows with the critical set\nMaintenance risk | an out-of-date slice paints the first screen wrong, then corrects | the desktop-only extraction in the run\nDuplicate rules | the full stylesheet repeats the critical rules. This is harmless: the inline block comes first and the full file, which contains every rule in its original order, loads later, so wherever the two disagree the full file wins and the result is exactly what the full file alone would give | the demo loads the whole file as the async part\n\nweb.dev also describes it as \"an advanced performance technique that can improve performance, but can also lead to bugs\", and says most sites should be able to reach its recommended performance targets without it. A sensible rule: use it when the measured First Contentful Paint (or Largest Contentful Paint) is held back by a blocking stylesheet request, and the first screen's CSS is small enough to inline.\n\nFor a marketing landing page\n\nA landing page is the best case: one template, a large first-screen hero, and a lot of CSS for below-the-fold sections. Extract once per template at phone and desktop sizes, inline the slice, preload the one web font the headline uses, and load the remaining stylesheet asynchronously. If the page is server-rendered, do the extraction at build time and inline from a template variable so every server response carries it.\n\nRunning the code\n\nmkdir demo && cd demo && echo '{\"type\":\"module\"}' > package.json\nnpm i playwright@1.48.2 # save the listing above as critical-css-demo.mjs\ndocker run --rm -v \"$PWD\":/w -w /w mcr.microsoft.com/playwright:v1.48.2-jammy sh -c 'node critical-css-demo.mjs; echo exit=$?'\nadd -e BREAK=empty (no critical CSS) or -e BREAK=blocking (blocking load) to run broken: FAIL lines and exit=1"}}]}Home Frontend Developer Interview Preparation Guide - Mid Level at Spotify Interview Process Overview Spotify's frontend engineer interview process for mid-level candidates typically follows a standard tech industry format with initial recruiter engagement, followed by technical phone screens to assess coding and problem-solving abilities, and onsite rounds covering frontend-specific technical skills, system design thinking, and cultural fit. The process evaluates your ability to build scalable user-facing applications, understand frontend architecture, and collaborate across teams.
Interview Rounds 1
Recruiter Screening 30 min4 focus topics culture fit What to Expect Initial phone call with a Spotify recruiter to assess background fit, career goals, and general interest in the role. They will verify your experience level, technical background, and availability. This is a mutual fit conversation where you should ask questions about the team, role expectations, and Spotify's culture.
Tips & Advice Be clear and concise about your frontend experience and growth trajectory. Highlight projects where you took ownership. Ask about the team size, tech stack, and what success looks like in the first 90 days. Express genuine interest in Spotify's product and mission. Prepare 2-3 questions that show you've researched the company.
Focus Topics Role Expectations and Questions Prepare thoughtful questions about team structure, current projects, growth opportunities, and technical challenges the team faces.
Practice Interview Start Practice
Study Questions Study Questions Technical Stack Alignment Discuss your experience with React, TypeScript, and modern frontend tooling/frameworks that align with Spotify's technology choices.
Practice Interview Start Practice
Study Questions Study Questions Explain why you're interested in Spotify specifically, what appeals to you about the company's mission, products, and culture.
Practice Interview Start Practice
Study Questions Study Questions Career Background and Experience Summary Clearly articulate your 2-5 years of frontend development experience, key projects, and career progression from entry/junior to mid-level.
Practice Interview Start Practice
Study Questions Study Questions 2
Technical Phone Screen - Frontend Coding 60 min4 focus topics technical What to Expect A 60-minute phone interview focused on frontend-specific coding problems and JavaScript/TypeScript fundamentals. You'll solve 1-2 coding problems on a shared coding environment (CoderPad or similar), explain your approach, and discuss trade-offs. The interviewer will assess your problem-solving process, code clarity, and ability to explain your thinking.
Tips & Advice Think aloud as you solve problems—explain your approach before coding. Start with a brute force solution if needed, then optimize. Focus on clean, readable code with proper naming conventions. For mid-level, you should solve problems efficiently but may need minor hints. Practice common array/string manipulation, DOM manipulation, and basic algorithm problems. Have a working development environment ready and test your code mentally before submitting.
Focus Topics DOM Manipulation and Browser APIs Practical knowledge of DOM selection, event handling, event delegation, form handling, and modern browser APIs relevant to frontend development.
Practice Interview Start Practice
Study Questions Study Questions Problem-Solving Communication Clearly articulating your approach, asking clarifying questions, discussing trade-offs, and iterating on solutions with interviewer feedback.
Practice Interview Start Practice
Study Questions Study Questions JavaScript/TypeScript Core Fundamentals Deep understanding of closures, prototypes, async/await, promises, event loop, scope, and hoisting. Strong grasp of ES6+ features and TypeScript basics.
Practice Interview Start Practice
Study Questions Study Questions Array and String Manipulation Algorithms Solving problems involving array methods (map, filter, reduce), string operations, and basic algorithmic thinking (two pointers, sliding window, etc.).
Practice Interview Start Practice
Study Questions Study Questions 3
Technical Phone Screen - React and Frontend Architecture 50 min4 focus topics technical What to Expect A 45-60 minute phone interview focused on React-specific knowledge and frontend architecture decisions. You'll answer questions about component design, state management, performance optimization, and may code a simple React component or discuss how you'd approach a frontend feature. The focus is on your understanding of React patterns and how you design scalable frontend systems.
Tips & Advice Be prepared to discuss real React patterns you've used in production—hooks, context API, component composition, memoization strategies. Know the trade-offs between different state management approaches. Discuss performance optimization techniques (code splitting, lazy loading, memoization). If asked to code a component, focus on clarity and proper React patterns. For mid-level, show that you think about scalability and team maintainability, not just making things work. Be ready to explain why you'd make specific architectural choices.
Focus Topics Performance Optimization in React Techniques like memoization (React.memo, useMemo, useCallback), code splitting, lazy loading components, reducing re-renders, and profiling tools.
Practice Interview Start Practice
Study Questions Study Questions Component Design and Reusability Designing composable, reusable components; prop drilling vs. context; compound components; and architectural patterns for scaling frontend codebases.
Practice Interview Start Practice
Study Questions Study Questions React Fundamentals and Hooks Solid knowledge of React concepts: components, JSX, props, state, lifecycle (class and functional), hooks (useState, useEffect, useContext, custom hooks), and when to use each.
Practice Interview Start Practice
Study Questions Study Questions State Management and Data Flow Understanding of state management patterns: local component state, lifting state up, context API, Redux or similar solutions. Knowledge of when to use each approach and trade-offs.
Practice Interview Start Practice
Study Questions Study Questions 4
Onsite Technical Interview - Frontend Coding 60 min5 focus topics technical What to Expect A 60-minute in-person or video interview focused on complex frontend coding problems. You'll work on 1-2 problems that may combine HTML, CSS, and JavaScript, or focus on implementing UI components or interactive features. Problems typically require mid-level complexity: understanding of HTML semantics, CSS layout and responsive design, JavaScript interactivity, and integration between layers. Expect questions that test your ability to build real user-facing features.
Tips & Advice Approach these problems methodically: clarify requirements, ask about edge cases and browser support, then build incrementally. Think about user experience and accessibility. Write clean, semantic HTML and modular CSS. For mid-level, interviewers expect you to own the full stack of a feature (HTML/CSS/JS integration) with minimal guidance. Ask for feedback during the interview. Test your solution across scenarios. Discuss performance implications of your approach. Be prepared to extend or modify your solution based on new requirements.
Focus Topics Problem Decomposition and Feature Ownership Breaking down complex UI problems into smaller components, planning implementation approach, and demonstrating end-to-end feature ownership without constant guidance.
Practice Interview Start Practice
Study Questions Study Questions Building Pixel-Perfect UI Components Implementing UI components that match design specifications, handling edge cases (hover states, focus states, disabled states, responsive breakpoints), and attention to visual details.
Practice Interview Start Practice
Study Questions Study Questions HTML Semantics and Accessibility Writing semantic HTML markup, understanding accessibility standards (WCAG), ARIA attributes, keyboard navigation, screen reader compatibility, and semantic element usage.
Practice Interview Start Practice
Study Questions Study Questions JavaScript Interactivity and DOM Manipulation Implementing interactive features: event handling, form validation, dynamic DOM updates, event delegation, and optimizing DOM operations for performance.
Practice Interview Start Practice
Study Questions Study Questions Responsive CSS and Layout Mastery of modern CSS layout techniques (Flexbox, CSS Grid), media queries, responsive design patterns, CSS architecture, BEM or similar naming conventions, and cross-browser compatibility.
Practice Interview Start Practice
Study Questions Study Questions 5
Onsite Technical Interview - Frontend System Design 60 min5 focus topics system design What to Expect A 45-60 minute interview assessing your ability to think about scalable frontend architecture. You'll be asked to design a user-facing feature or system, considering factors like component architecture, state management strategy, API integration patterns, performance optimization, scalability, and team considerations. Unlike backend system design, the focus is on frontend-specific concerns: component hierarchy, state flow, performance optimization, and how frontend systems integrate with backend services. You may discuss building a feature like a music player, recommendation feed, or checkout experience.
Tips & Advice For mid-level frontend system design, clearly scope the problem and state assumptions. Draw diagrams showing component hierarchy and data flow. Discuss state management strategies and justify your choices. Talk about performance considerations (code splitting, lazy loading, caching strategies). Explain how you'd handle real-time updates or high-frequency data changes. Discuss API integration patterns and error handling. Show awareness of team scalability—how would junior developers understand and extend this system? Be prepared to adjust your design based on constraints (performance requirements, team size, timeline). At mid-level, interviewers expect thoughtful architectural decisions, not just 'make it work' thinking.
Focus Topics API Integration and Data Fetching Patterns Designing data fetching strategies, caching patterns, handling loading/error states, optimistic updates, request deduplication, and synchronizing multiple data sources.
Practice Interview Start Practice
Study Questions Study Questions Scalable Frontend Architecture Decisions Making architectural trade-offs considering team size, code maintainability, extensibility, onboarding new developers, and long-term sustainability of the system.
Practice Interview Start Practice
Study Questions Study Questions Frontend Performance Optimization Code splitting and bundling strategies, lazy loading, memoization, virtual scrolling for large lists, image optimization, and identifying performance bottlenecks using profiling tools.
Practice Interview Start Practice
Study Questions Study Questions Frontend Component Architecture Designing scalable component hierarchies, determining component responsibilities, planning data flow through components, and creating reusable component libraries.
Practice Interview Start Practice
Study Questions Study Questions State Management at Scale Choosing appropriate state management solutions (local state, context API, Redux, etc.), designing normalized state shapes, data flow patterns, and handling state complexity as features grow.
Practice Interview Start Practice
Study Questions Study Questions 6
Onsite Behavioral and Culture Fit Interview 45 min5 focus topics behavioral What to Expect A 45-minute interview with a Spotify engineer or manager assessing cultural fit, collaboration skills, growth mindset, and how you've handled real-world challenges. You'll discuss past projects, conflicts, learning experiences, and how you work in teams. For mid-level candidates, this round evaluates your ability to mentor others, influence decisions, and contribute to team dynamics beyond code. Expect questions about your biggest challenges, how you've grown, how you handle ambiguity, and your approach to continuous learning.
Tips & Advice Use the STAR method (Situation, Task, Action, Result) for structured storytelling. Prepare 5-6 concrete examples from your 2-5 years of experience: a time you led technical decision-making, resolved a team conflict, mentored a junior developer, handled a challenging project, dealt with ambiguous requirements, or learned something significant. Emphasize ownership, collaboration, and growth. Show genuine interest in Spotify's mission and culture—research their engineering blog and publicly available content. Discuss how you stay current with frontend technologies. Be honest about failures and what you learned. For mid-level, interviewers want to see that you're not just coding but thinking about impact and team contribution.
Focus Topics Spotify Mission Alignment and Values Authentic interest in Spotify's mission around music and podcasting, understanding their values, and articulating how you'd contribute to Spotify's product vision.
Practice Interview Start Practice
Study Questions Study Questions Problem-Solving Under Ambiguity Examples of handling unclear requirements, making assumptions, asking clarifying questions, and driving projects forward with incomplete information.
Practice Interview Start Practice
Study Questions Study Questions Growth Mindset and Continuous Learning Show how you stay current with frontend technologies, pursue learning goals, adapt to new frameworks or tools, and approach challenges as learning opportunities.
Practice Interview Start Practice
Study Questions Study Questions Collaboration and Cross-Functional Communication Examples of working effectively with designers, backend engineers, product managers, and other teams. Show how you communicate technical concepts to non-technical stakeholders.
Practice Interview Start Practice
Study Questions Study Questions Leadership and Mentorship Experience Demonstrate experiences where you took initiative, led technical decisions, or mentored junior developers. Show how you've grown from individual contributor to someone who helps others.
Practice Interview Start Practice
Study Questions Study Questions Frequently Asked Frontend Developer Interview Questions JavaScript and TypeScript Fundamentals Easy Technical
In what order does JavaScript enumerate an object's keys? Show a surprising case where code that relies on that order produces wrong results, for example when keys look like integers.
Sample AnswerDirect answer
For an ordinary object, Reflect.ownKeys, Object.keys, Object.entries, JSON.stringify and for...in all visit keys in this order: first the keys that are array indices (canonical integer strings from "0" up to "4294967294", such as "2" or "10") in ascending numeric order, then the other string keys in the order they were first added, and, for Reflect.ownKeys and Object.getOwnPropertySymbols, the symbol keys last in creation order (Object.keys, for...in and JSON.stringify skip symbols). MDN states this rule for for...in and notes that Object.keys follows the same order. The surprise is the first group: a key that looks like an array index is moved to the front and sorted, so insertion order is not preserved for it. Integer-looking strings outside that range (negative, with a leading zero, fractional, or 4294967295 and above) are ordinary string keys.
The surprising case, run
js
const o = {};
o.banana = 1;
o["10"] = "ten";
o.apple = 2;
o["2"] = "two";
o[Symbol("tag")] = "sym";
o["-1"] = "negative";
o["01"] = "leading zero";
o["1.5"] = "fraction";
o["4294967295"] = "past the largest array index";
console.log(Reflect.ownKeys(o));
// Jobs keyed by numeric id; the author expects first inserted = first processed.
const queue = {};
queue[30] = "email";
queue[5] = "resize";
queue[12] = "report";
for (const id in queue) console.log("object:", id, queue[id]);
const jobs = new Map();
jobs.set(30, "email");
jobs.set(5, "resize");
jobs.set(12, "report");
for (const [id, job] of jobs) console.log("map: ", id, job);
console.log(JSON.stringify({ b: 1, 2: "x", a: 2, 1: "y" }));
const reassigned = { x: 1, y: 2 };
reassigned.x = 9;
console.log(Object.keys(reassigned));
const readded = { x: 1, y: 2 };
delete readded.x;
readded.x = 3;
console.log(Object.keys(readded));
Run in node:22, it prints:
text
[
'2', '10',
'banana', 'apple',
'-1', '01',
'1.5', '4294967295',
Symbol(tag)
]
object: 5 resize
object: 12 report
object: 30 email
map: 30 email
map: 5 resize
map: 12 report
{"1":"y","2":"x","b":1,"a":2}
[ 'x', 'y' ]
[ 'y', 'x' ]
Reading it:
In the first list, "2" and "10" jump ahead of banana and apple even though they were added after banana, and "2" precedes "10" because the comparison is numeric, not alphabetical. The next four keys are plain strings in creation order, because "-1" (negative), "01" (leading zero), "1.5" (fraction) and "4294967295" are not canonical array indices. 4294967295 is 2^32 - 1, one above the largest array index (2^32 - 2), so it stays with the strings. The symbol comes last in Reflect.ownKeys.
The job queue is the bug. The author inserted 30, then 5, then 12 and expects email, resize, report. The object yields 5, 12, 30 because the ids look like integers. A Map returns 30, 5, 12, its insertion order, for any key type.
JSON.stringify follows the same rule, so { b: 1, 2: "x", a: 2, 1: "y" } serializes with "1" and "2" first. Re-parsing and re-serializing therefore reorders such keys.
Assigning to an existing key keeps its position (x stays first). Deleting a key and adding it again moves it to the end of the string keys (y, then x).
Where code relying on order goes wrong
Any logic that assumes "insertion order" for objects whose keys can be numeric ids: a most-recently-used cache keyed by id, a FIFO queue or an undo stack keyed by timestamp or counter, an ordered form-field or column map keyed by numeric ids, a response object whose consumers expect "first key = oldest". Those pass tests with word-like keys ("a", "b") and fail in production when ids arrive. The mistake is also easy to hide because the keys are strings after the first assignment: queue[30] stores the string key "30".
How to fix it
Use a Map whenever order matters and keys are arbitrary. Iteration is always insertion order, keys keep their type (a number stays a number), and delete then set moves the entry to the end.
Use an array of entries ([[id, job], ...]) when the data must survive JSON, because JSON objects are unordered by contract in other languages' parsers even though JavaScript is consistent.
Store an explicit sort field (position, createdAt) when the order is a business rule, and sort on it, rather than depending on key enumeration.
Prefix keys ("id:30") only as a last resort for legacy objects: it avoids the integer rule but changes every key you read.
Pitfalls worth naming: do not add or delete properties while iterating with for...in; MDN notes a property added during the loop will not be visited and that deleting entries can behave differently across engines. Do not iterate arrays with for...in (it yields string indices and any extra enumerable properties); use for...of.
Running the code
text
node:22, save the listing as order.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 order.mjs'
Frontend Component and State Architecture Hard System Design
A dashboard has twelve widgets reading overlapping data that changes every few seconds. How do you design client-side caching and invalidation so the network stays quiet but users see fresh numbers when it matters? After a user edits an item, which cached data do you refetch, patch or drop?
Sample AnswerThe design rule is: one cache entry per piece of server data, many widgets reading it, and invalidation that is as narrow as what the edit actually changed. Twelve widgets should never mean twelve requests: each widget subscribes to a shared query by key and derives its own view from the result. Volatile numbers are refreshed by one poll per key, not per widget. After a user edits an item, patch the entries that the server's response already describes, refetch the entries the server computes, and either mark stale or drop the entries nobody is looking at. A broad "invalidate everything" is the simplest correct answer, and it is the one that makes a quiet network noisy.
Structure of the cache
A key per piece of data. Here: ['items'] (the list), ['item', id] (one record), ['stats'] (server-computed totals). A query key (an array that identifies the cached entry) is what lets two components share one fetch.
Widgets read, they do not fetch. Each widget calls a shared hook (a function a component calls; here each is a thin wrapper over useQuery from TanStack Query, a library that fetches server data into React components and keeps a cache of it) and passes select, an option holding a function that derives what the widget shows (a count, a name) from the cached data. Five widgets deriving from the list are one request; the select function runs on the cached data.
Request deduplication. If a key is already being fetched, a second subscriber joins that request instead of starting another. Demonstrated below: twelve widgets, three distinct keys, three requests.
Freshness tiers. Different keys get different rules: totals that change every few seconds get a poll (refetchInterval, an option that refetches on a timer), the item list gets a longer staleTime (how long fetched data counts as fresh) plus refetch on focus, and reference data gets a long window. One poll belongs to the key, so four widgets showing totals cost the same as one.
The demo's dashboard has 12 widgets that read just three keys: 5 widgets read ['items'], 4 read ['stats'] and 3 read ['item', 2], and 5 + 4 + 3 = 12. Deduplication therefore turns 12 widgets into 3 requests.
Measured behaviour
The demo runs 12 widgets (5 reading the item list, 4 reading stats, 3 reading item 2) under TanStack Query v5.59.0 against a fake server that counts requests per key, with intervals scaled down to milliseconds. Run in a node:22 container (Node 22.23.3, React 18.3.1, jsdom), it prints:
text
1. 12 widgets mounted -> requests per key: {"items":1,"stats":1,"item:2":1}
2. targeted edit -> screen: ["3:Alpha","3:Beta v2","3:Gamma","total 65","Beta v2"] requests: {"stats":1}
3. broad invalidateQueries() -> requests: {"items":1,"stats":1,"item:2":1}
4. after edit, requests for hidden lists: {}
reopened, first paint: {"open":"Alpha,Beta,Gamma","sorted":"loading"}
after revalidation: Alpha,Zeta,Gamma | requests: {"items:{\"status\":\"open\"}":1,"items:{\"sort\":\"name\"}":1}
mounted list, refetchType none -> requests: {}
mounted list, default refetchType -> requests: {"items:{\"status\":\"open\"}":1}
5. stats polled every 60 ms by 4 widgets for ~400 ms: stats requests within [4, 9]: true
all assertions passed
Mount: {"items":1,"stats":1,"item:2":1} is 3 requests for 12 widgets.
Targeted edit (item 2 renamed "Beta v2" and its amount changed from 20 to 25): the two list-derived widgets that show item 2 and the three item widgets show the new name with no request. Only stats is fetched, once, and the totals widgets move from 60 to 65. Total requests: 1. In the demo's cache the keys are ['items'], ['stats'] and ['item', 2]; "the two list-derived widgets that show item 2" are the ones whose select output includes the renamed row.
Broad invalidateQueries(): the same edit refetched all three active keys: 3 requests, two of which (the list and item 2) fetched data the edit's own response had already described.
Hidden entries: after the edit, the filtered list was marked stale and the sorted list was removed. Neither caused a request at edit time ({}). Reopening them: the stale one painted its old content at once (Alpha,Beta,Gamma), then revalidated to the new name; the dropped one painted loading, because no copy was kept. With no widget mounted, the edit-time {} would also appear without refetchType: 'none', because invalidateQueries only refetches queries that a mounted widget is reading. The last two printed lines therefore mount the filtered list and invalidate it twice: with refetchType: 'none' it makes no request, and with the default it refetches at once (1 request).
Polling: four widgets on the same polled key issued a request count consistent with one timer (between 4 and 9 requests in about 400 ms at a 60 ms interval; four independent timers would produce roughly four times as many).
The bounds in case 5 are loose on purpose, because timer scheduling varies from run to run.
After an edit: refetch, patch or drop
The calls in the demo do the following. setQueryData writes a value straight into the cache. invalidateQueries marks matching entries stale and refetches the ones a mounted widget is reading; matching is by prefix, so the key ['items'] also matches ['items', { status: 'open' }]. refetchType: 'none' marks entries stale without fetching now. A predicate is a function that picks which of the matching entries to act on. removeQueries deletes entries from the cache.
Cached data Action Why The edited record (['item', 2]) Patch with the server's response The response is the new truth. A refetch repeats the request for data you hold The same record inside the list Patch the row Keeps the list and the detail in agreement without a round trip Totals and anything the server computes from many rows Refetch The client cannot recompute them reliably (rules, rounding, permissions, rows it has not loaded) Off-screen lists whose rows and order the edit cannot change (the demo's list filtered by status: 'open', when the edit renames an item) Mark stale without fetching They refetch when next used, and the user sees something immediately Off-screen lists whose membership (which rows belong in the list) or order the edit may have changed (the demo's list sorted by name, when the edit renames an item) Drop An old order is misleading; showing a spinner is better than showing wrong order Everything else Leave alone Unrelated entries keep their freshness window
Two practical points follow. First, cancel or ignore any in-flight request (one that has been sent and not yet answered) for a key before writing a patch to it, because an older response that lands afterwards would overwrite the newer patch. Second, for an optimistic edit (patch the cache before the server answers, so the screen shows the change at once), keep the previous value so you can restore it if the request fails; this adds one step to targeted patching and nothing else changes.
The invalidateQueries reference confirms the prefix behaviour the demo relies on: invalidation marks matching queries stale and refetches those that are in use, and a key such as ['todos'] matches every query whose key begins with todos. That is why a predicate or an exact key is needed to keep ['items'] from sweeping up ['items', { status: 'open' }].
Trade-offs and pitfalls
Patching needs a trustworthy response. If the endpoint returns only an acknowledgement, refetch the entity instead.
Key design is the design. Include every parameter that changes the result in the key, and keep the same shape everywhere, or two widgets will not share and invalidation will miss.
Polling has a cost proportional to the number of keys, not widgets. Prefer a server-pushed channel only when the interval needed is much shorter than a few seconds.
Do not widen invalidation to fix a stale-looking widget; find which key it reads and why that key was not touched.
Verify in the Network panel (the browser DevTools tab that lists every request the page sends) that an edit produces the requests in the table and no others.
Code
js
import { JSDOM } from 'jsdom';
import assert from 'node:assert/strict';
const dom = new JSDOM('<!doctype html><body></body>', { url: 'http://localhost/' });
globalThis.window = dom.window;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator });
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const React = (await import('react')).default;
const { useState, useEffect, useMemo, useCallback, useRef, memo, useReducer } = React;
const { render, screen, fireEvent, cleanup, act, waitFor } = await import('@testing-library/react');
const h = React.createElement;
globalThis.IS_REACT_ACT_ENVIRONMENT = false; // data arrives from timers outside act(), as in a browser
const { QueryClient, QueryClientProvider, useQuery } = await import('@tanstack/react-query');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Fake server: three items; stats are computed on the server from the items.
const db = { items: [{ id: 1, name: 'Alpha', amount: 10 }, { id: 2, name: 'Beta', amount: 20 }, { id: 3, name: 'Gamma', amount: 30 }] };
const hits = {}; // requests per key name
const net = async (name, value) => { hits[name] = (hits[name] || 0) + 1; await sleep(10); return structuredClone(value()); };
const api = {
items: (f) => net(f ? 'items:' + JSON.stringify(f) : 'items', () => (f?.sort === 'name' ? [...db.items].sort((a, b) => a.name.localeCompare(b.name)) : db.items)),
item: (id) => net('item:' + id, () => db.items.find((i) => i.id === id)),
stats: () => net('stats', () => ({ total: db.items.reduce((s, i) => s + i.amount, 0) })),
edit: async (id, patch) => { Object.assign(db.items.find((i) => i.id === id), patch); await sleep(10); return structuredClone(db.items.find((i) => i.id === id)); },
};
const resetHits = () => { for (const k of Object.keys(hits)) delete hits[k]; };
// Shared hooks: widgets never call fetch themselves, they read the same keys.
const useItems = (select) => useQuery({ queryKey: ['items'], queryFn: () => api.items(), staleTime: 30_000, select });
const useItem = (id) => useQuery({ queryKey: ['item', id], queryFn: () => api.item(id), staleTime: 30_000 });
const useStats = (extra = {}) => useQuery({ queryKey: ['stats'], queryFn: () => api.stats(), staleTime: 30_000, ...extra });
// Twelve widgets: 5 derive from the items list, 4 read stats, 3 read item 2.
const widgets = [
...[0, 1, 2, 3, 4].map((i) => function W() { const q = useItems((d) => d.length + ':' + d.map((x) => x.name)[i % 3]); return h('li', { 'data-w': 'items' + i }, q.data ?? '..'); }),
...[0, 1, 2, 3].map((i) => function W() { const q = useStats(globalThis.pollMs ? { refetchInterval: globalThis.pollMs } : {}); return h('li', { 'data-w': 'stats' + i }, q.data ? 'total ' + q.data.total : '..'); }),
...[0, 1, 2].map((i) => function W() { const q = useItem(2); return h('li', { 'data-w': 'item' + i }, q.data ? q.data.name : '..'); }),
];
const Dashboard = () => h('ul', null, widgets.map((W, i) => h(W, { key: i })));
const shown = () => [...document.querySelectorAll('[data-w]')].map((e) => e.textContent);
// 1. Twelve widgets, three distinct keys: three requests.
globalThis.pollMs = 0;
let client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
let view = render(h(QueryClientProvider, { client }, h(Dashboard)));
await waitFor(() => assert.ok(!shown().includes('..')));
console.log('1. 12 widgets mounted -> requests per key:', JSON.stringify(hits));
assert.deepEqual(hits, { items: 1, stats: 1, 'item:2': 1 });
const sumHits = Object.values(hits).reduce((a, b) => a + b, 0);
assert.equal(sumHits, 3);
// 2. After an edit to item 2: patch what the response already contains, refetch what the server computes.
const before = { ...hits };
async function editItem2(strategy) {
const saved = await api.edit(2, { name: 'Beta v2', amount: 25 });
if (strategy === 'targeted') {
client.setQueryData(['item', 2], saved); // patch: the response is the new truth
client.setQueryData(['items'], (old) => old.map((i) => (i.id === 2 ? saved : i))); // patch the same row inside the list
await client.invalidateQueries({ queryKey: ['stats'] }); // refetch: the server computes totals
await client.invalidateQueries({ // mark other cached lists stale, fetch nothing now
queryKey: ['items'], predicate: (q) => q.queryKey.length > 1, refetchType: 'none' });
} else {
await client.invalidateQueries(); // broad: everything cached is refetched if active
}
}
resetHits();
await editItem2('targeted');
await waitFor(() => assert.ok(shown().includes('total 65')));
console.log('2. targeted edit -> screen:', JSON.stringify([...new Set(shown())]), ' requests:', JSON.stringify(hits));
assert.deepEqual(hits, { stats: 1 });
assert.equal(shown().filter((s) => s === '3:Beta v2').length, 2); // both list-derived widgets that show item 2
assert.equal(shown().filter((s) => s === 'Beta v2').length, 3);
// 3. The same edit with one broad invalidation, for comparison.
view.unmount();
db.items[1] = { id: 2, name: 'Beta', amount: 20 };
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
view = render(h(QueryClientProvider, { client }, h(Dashboard)));
await waitFor(() => assert.ok(!shown().includes('..')));
resetHits();
await editItem2('broad');
await waitFor(() => assert.ok(shown().includes('total 65')));
console.log('3. broad invalidateQueries() -> requests:', JSON.stringify(hits));
assert.deepEqual(hits, { items: 1, stats: 1, 'item:2': 1 });
view.unmount();
// 4. Cached entries nobody is looking at: mark stale (refetch on next use) or drop.
db.items[1] = { id: 2, name: 'Beta', amount: 20 };
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await client.fetchQuery({ queryKey: ['items', { status: 'open' }], queryFn: () => api.items({ status: 'open' }), staleTime: 30_000 });
await client.fetchQuery({ queryKey: ['items', { sort: 'name' }], queryFn: () => api.items({ sort: 'name' }), staleTime: 30_000 });
resetHits();
await api.edit(2, { name: 'Zeta' });
client.invalidateQueries({ queryKey: ['items', { status: 'open' }], refetchType: 'none' }); // stale, kept on screen if reopened
client.removeQueries({ queryKey: ['items', { sort: 'name' }] }); // the order is now wrong: drop it
console.log('4. after edit, requests for hidden lists:', JSON.stringify(hits));
assert.deepEqual(hits, {});
function Tab({ filter, label }) {
const q = useQuery({ queryKey: ['items', filter], queryFn: () => api.items(filter), staleTime: 30_000 });
return h('p', { id: label, 'data-fetching': String(q.isFetching) }, q.data ? q.data.map((i) => i.name).join(',') : 'loading');
}
const tabs = render(h(QueryClientProvider, { client }, h(Tab, { filter: { status: 'open' }, label: 'open' }), h(Tab, { filter: { sort: 'name' }, label: 'sorted' })));
const first = { open: document.getElementById('open').textContent, sorted: document.getElementById('sorted').textContent };
console.log(' reopened, first paint:', JSON.stringify(first));
assert.equal(first.open, 'Alpha,Beta,Gamma'); // stale copy shown at once, revalidating
assert.equal(first.sorted, 'loading'); // dropped: nothing to show
await waitFor(() => assert.equal(document.getElementById('open').textContent, 'Alpha,Zeta,Gamma'));
console.log(' after revalidation:', document.getElementById('open').textContent, '| requests:', JSON.stringify(hits));
// 4b. The mounted list is the case refetchType decides: 'none' keeps it from refetching right now.
resetHits();
await client.invalidateQueries({ queryKey: ['items', { status: 'open' }], refetchType: 'none' });
await sleep(50);
console.log(' mounted list, refetchType none -> requests:', JSON.stringify(hits));
assert.deepEqual(hits, {});
await client.invalidateQueries({ queryKey: ['items', { status: 'open' }] });
console.log(' mounted list, default refetchType -> requests:', JSON.stringify(hits));
assert.deepEqual(hits, { 'items:{"status":"open"}': 1 });
tabs.unmount();
// 5. Polling the shared stats key from four widgets: one timer's worth of requests, not four.
globalThis.pollMs = 60;
db.items[1] = { id: 2, name: 'Beta', amount: 20 };
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
resetHits();
view = render(h(QueryClientProvider, { client }, h(Dashboard)));
await waitFor(() => assert.ok(!shown().includes('..')));
await sleep(400);
const polls = hits.stats;
view.unmount();
console.log('5. stats polled every 60 ms by 4 widgets for ~400 ms: stats requests within [4, 9]:', polls >= 4 && polls <= 9);
assert.ok(polls >= 4 && polls <= 9, 'one poll per interval, not one per widget');
console.log('all assertions passed');
process.exit(0);
Running the code
bash
mkdir dashboard && cd dashboard && printf '{"type":"module"}' > package.json
npm i react@18.3.1 react-dom@18.3.1 @testing-library/react@16.0.1 @testing-library/dom@10.4.0 jsdom@25.0.1 @tanstack/react-query@5.59.0
node dashboard-cache.mjs # the listing above, saved as dashboard-cache.mjs
Mentoring and Coaching Hard Technical
You have several people asking for your time as a mentor at once, on top of your own deliverables. How do you decide who gets your attention and when?
Sample AnswerDirect answer
Triage by urgency and impact first, protect your own deliverables with an explicit, communicated time-box, and convert repeat-pattern questions into reusable artifacts so future requests don't all cost you 1:1 time. Prioritization alone doesn't scale past a certain number of mentees; reusable resources are what let personalized-feeling mentoring keep up as the queue grows.
Triage and scaling approach
Triage each request on three axes. Is it blocking (them or someone downstream) versus a growth request with slack. How long would it actually take to unblock: a quick answer versus a real session. Is this a shape of question you've answered before, which is a signal to build something reusable rather than repeat yourself.
Route, don't just prioritize. Not everything needs to be you specifically. A growth-oriented question might be better answered by a peer with more direct expertise, freeing your time for things only you can unblock.
Time-box and communicate the SLA out loud. "I can give you twenty minutes now on the blocking piece; let's put the design question on tomorrow's slot" sets expectations honestly instead of leaving people guessing whether they've been deprioritized.
Build reusable async artifacts for repeat patterns. When you notice you've answered a variant of the same question more than once, that's the signal to invest in a recorded walkthrough, a short playbook, or an FAQ instead of repeating the synchronous session a third and fourth time. This is a genuinely different lever from prioritization: it lets you scale personalized-feeling help without your 1:1 time growing linearly with the number of people asking.
Maintain the artifacts deliberately. A playbook or recording that goes stale is worse than not having one, because people trust it and get misled. Whoever owns it, you or a rotating owner, needs a cadence to revisit and refresh it, not a one-time write-and-forget.
Worked example
You're juggling your own deliverable alongside three mentees asking for time at once: one is genuinely blocked, one has a growth-oriented design question with no real time pressure, and one is asking a version of a question you've now answered several times before. You give the blocked person a focused twenty minutes to unblock them. You schedule the design question for a defined slot the next day rather than squeezing it in now. And instead of walking the third person through it live again, you point them to an existing recorded walkthrough, or if one doesn't exist yet, you record a short one this time specifically because you can already tell it'll come up again.
Trade-offs and pitfalls
Treating every request as equally urgent burns you out and, worse, under-serves the person with the actually urgent need, because everyone gets a diluted amount of attention instead of the right amount going to the right place.
Over-investing in artifacts nobody maintains creates a different failure: a stale playbook actively misleads people and erodes trust faster than simply not having documentation and telling people to ask.
Prioritizing strictly by who's loudest or most urgent can systematically starve quieter mentees who don't escalate assertively. It's worth periodically checking who you haven't heard from, not just responding to who's asking.
If you find yourself using "I'll make you a doc" as a polite way to avoid ever giving someone real synchronous time, that's usually a sign the mentee queue has outgrown what one person can reasonably carry, and it's a resourcing conversation to raise with your own manager, not something to keep absorbing indefinitely.
Cross-Functional Collaboration Medium Technical
How do you keep a cross-functional team aligned and moving when the people involved are spread across time zones with little or no overlap in working hours?
Sample AnswerDirect answer
Keep alignment across time zones with three levers: shrink what actually needs real-time overlap by defaulting to async updates on a fixed template, protect a small deliberately scheduled overlap window for anything that truly needs live discussion, and make handoffs explicit in writing so context transfers cleanly across the boundary instead of depending on someone's memory.
Framework
Reduce dependence on overlap. Default to async status updates on a fixed cadence, and use written decision docs rather than requiring a live meeting for every decision. Most updates don't need a room, only genuinely ambiguous or high-stakes calls do.
Protect a deliberate overlap window. Negotiate a recurring block, even a short one, and rotate who takes the inconvenient time so the burden doesn't always fall on the same region.
Make handoffs explicit. When work crosses a time-zone boundary, produce a short written artifact rather than relying on a quick chat message. This matters most in ops-heavy, always-on contexts.
Worked example
Consider an on-call rotation providing 24/7 production coverage across three time zones (for example [Region A], [Region B], and [Region C]), where the two outer regions have little or no live overlap with each other.
Shadow and overlap periods: the incoming region's on-call shadows the outgoing region's on-call for a short deliberate window at the shift boundary, even 15 to 30 minutes, to ask questions live before the outgoing engineer signs off.
Written handoff template: a standard document filled at every handoff covering open incidents, any systems in a degraded state, changes deployed in the last shift, and explicit 'known risk' or 'do not touch' notes.
Escalation expectations: a written policy defining what counts as page-worthy versus a handoff note, who the secondary on-call is in each region, and how long the incoming engineer has to acknowledge before it auto-escalates.
Result: even with zero live overlap between two of the three regions, the written handoff plus the short shadow window from the middle region means each incoming on-call starts already briefed, instead of reconstructing state from raw logs.
For non-ops roles the same mechanism applies with a different artifact, for example a design or product handoff might be a written decision log plus a recorded walkthrough rather than an incident handoff, but the principle (explicit written handoff over a live conversation) is the same.
Trade-offs and pitfalls
Repeatedly scheduling occasional syncs at painful hours burns out whichever time zone draws the short straw. Rotate it deliberately.
Async-only breaks down for genuinely ambiguous or high-stakes decisions. Some live channel for true emergencies still has to exist.
A handoff template that's too heavy gets skipped under time pressure. Keep it short enough to fill in within a few minutes.
Assuming a chat message counts as a handoff is the actual failure mode this whole approach is designed to prevent. The structured artifact is the point, not the tool it's written in.
Clear Written and Verbal Communication Easy Technical
After a working meeting, write a concise summary (3-6 sentences) that captures the decision made, who owns each follow-up, the deadlines, and any question that is still open.
Sample AnswerDirect answer
Write a short summary right after the meeting that states the decision made, names an owner and deadline for each follow-up, and flags anything still unresolved, so nobody has to reconstruct what happened from memory a week later.
Structured elaboration
State the decision first , in one sentence, even if it feels obvious right after the meeting; it stops being obvious within a day or two, especially for people who weren't in the room.
List action items with an owner and a deadline each , not a bare to-do list; "someone should look into X" is not actionable, "Priya will check the vendor SLA by Thursday" is.
Name what's still open , explicitly, rather than letting it quietly drop; a one-line "not yet decided: whether we notify customers proactively" prevents someone assuming it was implicitly settled.
Send it promptly , ideally within the hour, while the details are fresh and before people have moved on to something else and stopped tracking it mentally.
Keep it short. Three to six sentences is usually enough; a summary that's as long as a transcript won't get read.
Worked example
"Decision: we're moving the schema migration to next Tuesday's low-traffic window instead of doing it live this week. Action items: Priya to update the migration runbook by Monday EOD; Sam to notify the on-call rotation of the new window by Friday. Open question: whether we need a customer-facing heads-up, still deciding, will confirm by Wednesday."
Three sentences, one decision, two owned action items with deadlines, and one explicitly flagged open item.
Trade-offs and pitfalls
The most common failure is writing a summary that lists what was discussed instead of what was decided; a meeting can generate a page of discussion and one real decision, and the summary should reflect that ratio.
An action item without a named owner tends to silently not get done; if you can't name an owner in the summary, that's a sign the meeting didn't actually resolve who's responsible.
Sending it too late (days later) defeats the purpose; by then people have already formed their own, sometimes conflicting, memory of what was agreed.
DOM Manipulation and Browser APIs Hard Technical
Given the following vanilla JS code snippet:
function createWidget(container) {
const el = document.createElement('div');
el.className = 'widget';
el.addEventListener('click', () => console.log(el.textContent));
container.appendChild(el);
}
Later the widget is removed with el.remove(). Identify why this code might leak memory in some scenarios, how to detect the leak with DevTools, and propose fixes to ensure the element is garbage-collected.
Sample AnswerDirect answer
As written, createWidget does not leak, even though the closure over el makes it look suspicious; what follows shows why, and the conditions under which a widget like it does leak. The click listener is attached to el itself and the arrow function refers to el, so the element and its listener point at each other, but a garbage collector (GC) frees objects that no live root can reach (a root is a starting point the engine always keeps: window, the variables of running code, pending timers), and a two-object cycle with no outside reference is unreachable once el.remove() detaches it. A run in the proof section below tracks the element with a WeakRef (a handle that reads the object while it exists but does not keep it alive; deref() returns undefined once it has been collected) and confirms the snippet's element is collected. It leaks "in some scenarios" only when something outside the pair still holds a reference: a registry or array that stored the element, a listener on document or window, a running timer, or a MutationObserver (a browser object that calls your function when the DOM changes) whose callback mentions el; an IntersectionObserver (which calls back when an element enters or leaves the viewport) behaves the same way. Find the holder with a heap snapshot in the DevTools Memory panel (filter by Detached), and fix it by giving the widget a destroy() that undoes every outside registration.
Why the snippet itself is safe
el.addEventListener('click', fn) stores fn in the listener list of el. fn closes over the variable el. So el -> fn -> el.
GC is a trace from roots (window, the browser's table of active timers, registered observers, the variables on the running call stack). Nothing on the path from a root reaches el once remove() has deleted its link to container.
Therefore both are freed together. Cleaning up a listener that sits on the element being removed is unnecessary.
The snippet does have a design weakness that makes the leaky scenarios likely: it returns nothing, so the caller gets no handle to destroy the widget, and anything the widget later registers elsewhere has no way to be undone.
Scenarios in which it does leak
Scenario What holds the removed element Release Caller stores widgets in a Map, Set or array "so it can find them later" The collection Delete the entry when the widget is removed, or key a WeakMap by the element The widget also listens on document or window (for key presses, resize, outside click) The target's listener list holds a closure over el removeEventListener, or an AbortSignal passed in the optionsA setInterval updates the widget The timer table holds the callback clearIntervalA MutationObserver or IntersectionObserver on a long-lived node has a callback that mentions el The observed node holds the observer, which holds the callback disconnect() and drop the observer variable
Detecting it in DevTools
Reproduce with a loop: create and remove the widget 100 times (or navigate away and back 100 times) so the leak is large and countable.
Memory panel, choose Heap Snapshot , press Take snapshot .
Type Detached in the Class filter box. Each entry is a detached DOM tree; expand it, click a node inside, and read the Objects pane to see which code references it (in DevTools' own example, a variable holding the tree).
Record a Detached elements profile when you want the exact HTML nodes and their count.
A number of detached trees close to the number of iterations (here, around 100) points to one reference per widget; the referencing object tells you which row of the table above you are in.
Fixes
Return a handle from the factory and make destroy() the single place that releases outside registrations. createWidgetFixed in the listing below uses one AbortController for every listener, clearInterval for the timer and disconnect() for the observer, then removes the element. Two other habits help: store an ID instead of the element in long-lived collections (look the element up when needed), and use one delegated listener on a stable parent for clicks, so per-widget listeners do not exist at all.
The proof
The test uses Playwright (a library that drives a real browser from Node) to build six widgets in Chromium launched with --js-flags=--expose-gc (a V8 flag that makes a global gc() function available, since ordinary pages cannot force a collection), keeps only WeakRefs, removes each element, forces a collection, and prints which were freed. A second pass clears each outside reference and collects again. heldByDocumentListener is released by calling abort() and dropping the controller, which is the same as the component being unmounted.
js
// Chromium only: window.gc() is exposed with --js-flags=--expose-gc and a WeakRef shows whether each
// removed widget element was collected.
import { chromium } from 'playwright';
import assert from 'node:assert/strict';
const html = `<!doctype html><body><div id="root"></div><script>
// The snippet from the question, unchanged.
function createWidget(container) {
const el = document.createElement('div');
el.className = 'widget';
el.addEventListener('click', () => console.log(el.textContent));
container.appendChild(el);
}
// Same snippet, but it returns the element so callers can keep it.
function createWidgetEl(container) {
const el = document.createElement('div');
el.addEventListener('click', () => console.log(el.textContent));
container.appendChild(el);
return el;
}
// Fixed version: returns a handle whose destroy() undoes everything the widget registered outside itself.
function createWidgetFixed(container) {
const el = document.createElement('div');
const ac = new AbortController();
el.addEventListener('click', () => console.log(el.textContent));
document.addEventListener('keydown', () => { el.dataset.key = '1'; }, { signal: ac.signal });
const timer = setInterval(() => { el.dataset.tick = '1'; }, 1000);
const observer = new MutationObserver(() => { el.dataset.seen = '1'; });
observer.observe(document.body, { childList: true });
container.appendChild(el);
return { el, destroy() { ac.abort(); clearInterval(timer); observer.disconnect(); el.remove(); } };
}
const root = document.getElementById('root');
const refs = {};
const registry = new Map();
let timer, observer, abortAll;
const cases = {
asWritten() { // nothing outside references the element
createWidget(root);
const el = root.lastChild; refs.asWritten = new WeakRef(el); el.remove();
},
heldByRegistry() { // a module-level Map remembers every widget
const el = createWidgetEl(root); registry.set('w', el); refs.heldByRegistry = new WeakRef(el); el.remove();
},
heldByDocumentListener() { // a listener on document closes over the element
const el = createWidgetEl(root); const ac = new AbortController(); abortAll = () => ac.abort();
document.addEventListener('keydown', () => { el.dataset.key = '1'; }, { signal: ac.signal });
refs.heldByDocumentListener = new WeakRef(el); el.remove();
},
heldByTimer() {
const el = createWidgetEl(root); timer = setInterval(() => { el.dataset.tick = '1'; }, 1000);
refs.heldByTimer = new WeakRef(el); el.remove();
},
heldByObserver() {
const el = createWidgetEl(root); observer = new MutationObserver(() => { el.dataset.seen = '1'; });
observer.observe(document.body, { childList: true }); refs.heldByObserver = new WeakRef(el); el.remove();
},
fixedHandle() {
const w = createWidgetFixed(root); refs.fixedHandle = new WeakRef(w.el); w.destroy();
},
};
window.run = async (names) => {
for (const n of names) cases[n]();
await new Promise((r) => setTimeout(r, 50)); // collect in a later task: WeakRef targets live to the end of their task
for (let i = 0; i < 3; i++) { gc(); await new Promise((r) => setTimeout(r, 20)); }
return Object.fromEntries(names.map((n) => [n, refs[n].deref() === undefined]));
};
window.release = async () => {
registry.clear(); abortAll(); abortAll = null; clearInterval(timer); observer.disconnect(); observer = null; // abort, then drop the controller too
await new Promise((r) => setTimeout(r, 50));
for (let i = 0; i < 3; i++) { gc(); await new Promise((r) => setTimeout(r, 20)); }
return Object.fromEntries(Object.keys(refs).map((n) => [n, refs[n].deref() === undefined]));
};
</script></body>`;
const browser = await chromium.launch({ args: ['--js-flags=--expose-gc'] });
const page = await browser.newPage();
await page.setContent(html);
const names = ['asWritten', 'heldByRegistry', 'heldByDocumentListener', 'heldByTimer', 'heldByObserver', 'fixedHandle'];
const collected = await page.evaluate((n) => window.run(n), names);
console.log('collected after el.remove(): ', JSON.stringify(collected));
assert.deepEqual(collected, { asWritten: true, heldByRegistry: false, heldByDocumentListener: false, heldByTimer: false, heldByObserver: false, fixedHandle: true });
const released = await page.evaluate(() => window.release());
console.log('after clearing each outside reference: ', JSON.stringify(released));
assert.ok(Object.values(released).every(Boolean));
await browser.close();
How the harness reads: cases holds six functions. Each builds one widget a different way, removes its element, and keeps only a WeakRef to it. run calls the chosen cases, waits 50 ms, then calls gc() three times with 20 ms pauses. The first wait matters because a WeakRef target stays alive until the end of the task that created it, so the collection has to happen in a later task; repeating gc() gives the engine more than one chance to finish. refs[n].deref() === undefined is therefore true exactly when widget n was collected. release clears the registry, aborts the listener, clears the timer and disconnects the observer, then collects again.
Output:
text
collected after el.remove(): {"asWritten":true,"heldByRegistry":false,"heldByDocumentListener":false,"heldByTimer":false,"heldByObserver":false,"fixedHandle":true}
after clearing each outside reference: {"asWritten":true,"heldByRegistry":true,"heldByDocumentListener":true,"heldByTimer":true,"heldByObserver":true,"fixedHandle":true}
In the first line true means collected and false means still alive after removal. asWritten is the question's snippet, unchanged, and it is collected. The four held... widgets are retained until their outside reference is released, and fixedHandle is collected right after destroy(). The second line is all true because releasing every outside reference let the collector reclaim all six elements.
Pitfalls
Do not add cleanup of listeners on the removed element itself out of habit. It costs code and hides the real holders.
el.remove() is not destruction. It only detaches; whatever else points at the element decides whether it lives.
Small leaks multiply. One leaked widget with 10 child nodes is invisible; a list that re-renders 500 times a session leaks thousands of nodes.
Collections hide leaks from reviewers. A Map of "active widgets" with no removal path is the most common real cause.
Complexity: creation and destruction are O(1) per widget plus its subtree; the leak cost is O(widgets created x subtree size) of retained memory.
Running the code
bash
mkdir widget && cd widget # save test-widget.mjs here
echo '{"type":"module"}' > package.json
npm i playwright@1.48.2 && npx playwright install chromium
node test-widget.mjs
Run in the mcr.microsoft.com/playwright:v1.48.2-jammy container (Node 20), where Chromium is already installed.
Frontend Fundamentals: HTML, CSS, and Responsive Styling Easy Technical
Explain the BEM naming methodology and how it helps maintainable styles. Given this component markup, propose BEM-style class names and a simple CSS example for an input group with help text and an error state:
html
<div class='input-group'>
<label>Amount</label>
<input />
<p class='help'>Enter a value</p>
</div>
Show BEM classes and a modifier for an error state.
Sample AnswerDirect answer
BEM (Block, Element, Modifier) is a class-naming convention. A block is a standalone component (input-group), an element is a part that only makes sense inside its block (input-group__help, joined by two underscores), and a modifier is a variation or state of a block or element (input-group__help--error, joined by two hyphens). Every selector is a single class, so specificity (the ranking the cascade uses when two rules compete) is the same everywhere and a rule's meaning does not depend on where the markup sits. That makes styles easy to search, safe to move, and hard to break by accident.
How BEM helps maintainability
Flat specificity. .input-group__input and .input-group__input--error both score (0,1,0): zero id selectors, one class, zero type selectors. Nothing needs !important or a longer selector to win; ordering in the file decides ties.
No dependence on DOM structure. .input-group__help works whether the paragraph is a child, a grandchild, or moved; compare .input-group > p, which breaks when someone wraps it.
Namespacing by block name. A generic .help or .error in one feature will collide with another's. Prefixing with the block makes collisions unlikely without any tooling (it is still a convention, so nothing enforces it).
Greppable and deletable. Searching input-group finds every rule and every use; deleting a component means deleting one block's rules.
Elements do not nest in names. Write card__title, never card__header__title; the element belongs to the block, not to another element.
The example: input group with help text and an error state
The original markup has two accessibility gaps worth fixing while adding classes: the <label> is not connected to the input, and the help text is not tied to it. Connect them with for/id and aria-describedby (an attribute that lists the id of the element whose text a screen reader, one kind of assistive technology, should read as the control's description). inputmode="decimal" asks phones to show a numeric keypad with a decimal point while the field stays a plain text input.
html
<div class="input-group">
<label class="input-group__label" for="amount">Amount</label>
<input class="input-group__input" id="amount" type="text" inputmode="decimal" aria-describedby="amount-help">
<p class="input-group__help" id="amount-help">Enter a value</p>
</div>
css
/* Block: input-group. Elements: label, input, help. Modifier: error (set on each part that changes). */
.input-group { display: grid; gap: 4px; max-width: 20rem }
.input-group__label { font-weight: 600 }
.input-group__input { padding: 8px; border: 2px solid rgb(118, 118, 118); border-radius: 4px }
.input-group__help { margin: 0; font-size: 0.875rem; color: rgb(85, 85, 85) }
/* Modifiers come AFTER the base rules: same specificity (0,1,0), so source order decides */
.input-group__input--error { border-color: rgb(179, 38, 30) }
.input-group__help--error { color: rgb(179, 38, 30) }
/* A second block, btn, shows how a modifier and a native state compete for the same property.
.btn--primary sets opacity: 1 on purpose, so it conflicts with the :disabled rule below. */
.btn { display: inline-flex; align-items: center; padding: 8px 16px; border: 0; background: rgb(11, 92, 173); color: #fff; opacity: 1 }
.btn__icon { width: 1em; height: 1em; margin-right: 8px }
.btn--primary { opacity: 1 } /* modifier, (0,1,0) */
.btn:disabled { opacity: 0.5; cursor: not-allowed } /* native state, (0,2,0): beats the modifier whatever the order */
When validation fails, the code adds the modifiers and swaps the text:
html
<input class="input-group__input input-group__input--error" id="amount" type="text"
inputmode="decimal" aria-describedby="amount-help" aria-invalid="true">
<p class="input-group__help input-group__help--error" id="amount-help">Error: enter a number such as 12.50</p>
Choices to state out loud:
A modifier is added next to the base class, never instead of it (class="input-group__input input-group__input--error"), so the modifier only overrides what changes.
Modifier on each part vs on the block. Putting input-group--error on the wrapper and writing .input-group--error .input-group__help { ... } also works and needs one class toggle, but the selector scores (0,2,0) and depends on structure. The flat form above keeps every rule at (0,1,0) at the cost of toggling two classes. Pick one per codebase.
Colour is not the only signal. The text itself changes ("Error: ..."), and aria-invalid="true" records the state for assistive technology (software such as screen readers that reads the page aloud or in braille). MDN defines it as indicating "the entered value does not conform to the format expected by the application" and recommends pairing it with styling and messaging. MDN also suggests styling with the [aria-invalid="true"] attribute selector, which keeps the style and the accessibility state in sync. On its own, an attribute selector scores (0,1,0), the same as one class. Combined with the element's class, .input-group__input[aria-invalid="true"] scores (0,2,0): one class plus one attribute.
Native states are not modifiers. For the button, use :disabled rather than .btn--disabled, because disabled is a built-in state of the element rather than a style variation. .btn:disabled scores (0,2,0) (one class plus one pseudo-class, a keyword such as :disabled that selects an element in a particular state), so it beats .btn--primary (0,1,0) whatever the order in the file.
Icon button. The icon is an element (btn__icon) and a look such as btn--primary is a modifier on the block; the icon <svg> is aria-hidden because the button text already names the control.
Check in three engines
js
import { chromium, firefox, webkit } from 'playwright';
import assert from 'node:assert/strict';
import fs from 'node:fs';
const css = fs.readFileSync('bem.css', 'utf8');
const html = `<!doctype html><meta charset="utf-8"><style>${css}</style>
<style>.btn--primary { opacity: 1 } /* repeated AFTER the :disabled rule, so only specificity can keep the button at 0.5 */</style>
<div class="input-group">
<label class="input-group__label" for="amount">Amount</label>
<input class="input-group__input" id="amount" type="text" inputmode="decimal" aria-describedby="amount-help">
<p class="input-group__help" id="amount-help">Enter a value</p>
</div>
<button class="btn btn--primary" id="save"><svg class="btn__icon" aria-hidden="true" viewBox="0 0 10 10"></svg>Save</button>
<button class="btn btn--primary" id="off" disabled>Save</button>`;
// what the app does when validation fails: swap text, add the modifiers, set aria-invalid
const markInvalid = () => {
const input = document.getElementById('amount'), help = document.getElementById('amount-help');
input.classList.add('input-group__input--error'); input.setAttribute('aria-invalid', 'true');
help.classList.add('input-group__help--error'); help.textContent = 'Error: enter a number such as 12.50';
};
const read = () => {
const cs = id => getComputedStyle(document.getElementById(id));
return { border: cs('amount').borderTopColor, help: cs('amount-help').color,
text: document.getElementById('amount-help').textContent,
off: cs('off').opacity, offCursor: cs('off').cursor,
iconGap: getComputedStyle(document.querySelector('.btn__icon')).marginRight };
};
for (const [name, type] of [['chromium', chromium], ['firefox', firefox], ['webkit', webkit]]) {
const browser = await type.launch();
const page = await browser.newPage();
await page.setContent(html);
const before = await page.evaluate(read);
assert.equal(await page.getByRole('textbox', { name: 'Amount' }).count(), 1); // label is associated with the input
await page.evaluate(markInvalid);
const after = await page.evaluate(read);
console.log(name.padEnd(8), JSON.stringify({ before: [before.border, before.help], after: [after.border, after.help, after.text], off: [after.off, after.offCursor], iconGap: after.iconGap }));
assert.equal(before.border, 'rgb(118, 118, 118)');
assert.equal(before.help, 'rgb(85, 85, 85)');
assert.equal(after.border, 'rgb(179, 38, 30)');
assert.equal(after.help, 'rgb(179, 38, 30)');
assert.equal(after.off, '0.5'); // :disabled (0,2,0) beats .btn--primary (0,1,0), even when that rule comes later
assert.equal(after.offCursor, 'not-allowed');
assert.equal(after.iconGap, '8px');
assert.equal(await page.getAttribute('#amount', 'aria-invalid'), 'true');
await browser.close();
}
console.log('all assertions passed');
Run in the Playwright 1.48.2 image (Chromium, Firefox, Playwright's WebKit build), all three printed the same values; Chromium shown:
text
chromium {"before":["rgb(118, 118, 118)","rgb(85, 85, 85)"],"after":["rgb(179, 38, 30)","rgb(179, 38, 30)","Error: enter a number such as 12.50"],"off":["0.5","not-allowed"],"iconGap":"8px"}
all assertions passed
Moving the two modifier rules above the base rules makes the border and help colours stay grey and fails the assertion: at equal specificity the later rule wins, so modifiers must follow their base. The second <style> element repeats .btn--primary after the :disabled rule, so the 0.5 opacity can only come from specificity: changing the selector to :where(.btn):disabled, which scores (0,1,0), fails the off assertion because the later .btn--primary then wins.
Pitfalls
Using BEM names but also writing .card .title selectors defeats the flat specificity that is the point.
A block that styles its own outer margin couples it to its context; let the parent or a layout block place it.
BEM does not stop another team's global p { ... } rule from affecting your block; it only prevents class-name collisions.
Running the code
bash
npm init -y && npm i playwright@1.48.2 # inside mcr.microsoft.com/playwright:v1.48.2-jammy
# add "type": "module" to package.json; save the CSS above as bem.css and the test as bem-test.mjs
node bem-test.mjs
Growth Mindset and Learning Agility Medium Technical
Say you are moving into an area you have not worked in before, either a new team or a different specialty. Lay out how you would spend the first three months, and how you would know month by month whether you were on track.
Sample AnswerDirect answer
I'd structure the three months as a small number of month-scale milestones, each with concrete evidence I'm actually on track, and I'd bias the early weeks toward habits, how I verify information, who actually knows what, how work really gets reviewed, over a rigid task list, since those habits compound and a task list rarely survives contact with how things actually work.
Structured elaboration
Month one is about orientation habits, not output. I focus on the meta-skills that determine how fast the whole ramp goes: how to verify what I'm told here, who actually has the answers versus who's just available, and how work really gets reviewed and shipped. I also pick one small but real piece of work, not a throwaway exercise, small enough to be safe but real enough to teach me the actual constraints, and finish it.
Month two expands scope with less hand-holding , and I deliberately pick a task that stretches a specific gap month one exposed, rather than repeating something month one already proved I could do.
Month three takes something closer to end-to-end with minimal supervision , and functions as the real check on whether the earlier ramp actually took, not just whether I felt more comfortable.
Track progress against visible evidence each month, not a feeling. A shipped piece of real work, a question I can now answer without help, a review I no longer need: these are checkable in a way "I feel more settled" isn't.
Keep running notes on what I'm learning as I go , mainly for myself: writing it down forces me to notice what I actually understand versus what I only think I understand, and it happens to save me from re-deriving the same answer a second time later.
Hold the longer arc in view. The point of a genuinely good first-ninety-days plan isn't just fitting into the new team, it's building toward what I'll be trusted with next, so I pick milestones that show growth, not just that I've reached the floor of the new role.
Worked example
Moving from a general security role into an application-security specialty I hadn't worked in directly before, I spent the first two weeks less on formal training material and more on habits: sitting in on a few real code reviews to see how security issues actually got raised and resolved here, and figuring out which two colleagues actually knew the history behind our trickiest existing systems. My first real piece of work was reviewing one moderate-risk change end to end, small enough that a mistake was recoverable, but real enough to teach me the team's actual review norms rather than the documented ones. By month two, I took on a task that specifically stretched a gap month one had exposed: I hadn't yet had to reason about a vulnerability class that came up more often here than in my old role, so I deliberately picked a task involving that. By month three, I led a review independently that would have needed a second pair of eyes back in month one, and used that as the actual evidence the ramp had worked, not just a feeling of familiarity. I kept a short running document of what I was learning throughout, which turned out useful a few months later when a similar issue came up and I could look back at my own notes instead of re-figuring it out from scratch.
Trade-offs and pitfalls
A plan that's all reading and passive orientation with no real work in the loop tends to feel productive without actually testing anything. The opposite mistake, front-loading too much scope before the meta-skills like who to ask and how review works are in place, tends to produce avoidable mistakes early that damage trust. And judging yourself only by how comfortable you feel, rather than by concrete evidence like a piece of finished work or a question you can now answer alone, is an easy way to think you're on track when you're not.
Navigating Ambiguity and Adaptive Planning Medium Technical
A key teammate, or the person leading a deliverable, leaves the project unexpectedly and cannot be replaced quickly, and you have to keep the work moving with reduced capacity. Walk through how you would replan the near-term roadmap: what you would triage or cut, what safeguards you would put in place so critical decisions still get proper review, how you would communicate the revised plan to stakeholders, and what you would document to reduce single-person dependency going forward.
Sample AnswerDirect answer
Separate two different problems that this situation creates: what work gets cut or deferred, and who now has the authority to make the calls the departed person used to make alone. The second one is the part most answers miss, and it matters most for whichever category of decision carries the highest risk if it goes unreviewed.
Decision framework
1. Triage by impact and reversibility. Classify remaining roadmap items into must-ship (a real customer or compliance commitment), valuable-but-deferrable, and nice-to-have. Cut the nice-to-have items immediately, and for the must-ship items, identify specifically which ones depended on the departed person's unique, undocumented knowledge.
2. Put a temporary safeguard on the highest-risk decisions specifically. Assign a temporary decision-owner, often the next most senior person or the manager, but require a mandatory second review for exactly the category of decision the departed person used to make solo, such as architecture or technical design calls, rather than letting one new person inherit unilateral authority by default.
3. Communicate the revised plan and the new decision process together. Stakeholders need both: what's cut or delayed, and who to go to for what while the arrangement is temporary.
4. Document to reduce single-person dependency going forward. Capture the departed person's undocumented reasoning, not just their outputs, and change the standing process so future high-risk work always has a documented secondary owner, not only as an emergency response this one time.
Worked example: the lead building a fraud-detection scoring pipeline, one of five people on the team, resigns with two weeks notice, and their replacement won't start for eight weeks. The quarter's roadmap has six remaining items. Triage: two items are customer-committed with a regulator-driven deadline (must-ship), three are deferrable roadmap improvements, and one is a nice-to-have refactor, which I cut for the quarter. Of the two must-ship items, one depends on undocumented model-threshold tuning logic that only the departed lead understood. Safeguard: I assigned a senior remaining engineer as temporary technical decision-owner for the pipeline, but required any threshold or architecture change to get a second review from a named machine learning engineer on an adjacent team, with a 24-hour service level agreement (SLA, a committed turnaround time) for that review, specifically because threshold changes carry real financial and compliance risk, and no single person on the reduced team had full context to safely decide alone. Communicate: I presented the revised roadmap (two must-ship items kept, three deferred, one cut) and the new temporary review process to department stakeholders and the departing lead's manager within the first week, explicit that this was an eight-week interim arrangement, not a permanent capacity cut. Document: I spent six hours of the departing lead's remaining two weeks in structured knowledge transfer specifically on the threshold-tuning logic, recorded as a written runbook plus a 40-minute screen-recorded walkthrough, and instituted a standing rule that any pipeline with real financial or compliance impact must have two people who can explain its core logic, verified at each quarterly review.
Second example (different discipline): a content team's sole search-engine-optimization (SEO) strategist leaves mid-quarter. The editorial lead cuts two experimental content formats, keeps the core publishing cadence, puts a temporary two-person review on any page-structure or metadata change since that was the departed strategist's unilateral domain, tells stakeholders to expect a lighter cadence for six weeks, and documents the strategist's undocumented keyword-research process into a shared playbook so the next hire isn't starting from zero.
Trap to avoid
The mediocre answer treats this purely as a staffing or backfill problem, "we'd hire quickly" or "redistribute the work," without addressing the governance gap: who now has the authority the departed person had, and what specifically stops a wrong high-stakes call from going unreviewed simply because there's no one left who would have caught it.
Frontend Performance and Rendering Optimization Easy Technical
What is critical CSS, why can inlining it improve perceived load time, and what are the downsides? How would you extract and ship it for a real site?
Sample AnswerDirect answer
Critical CSS is the small slice of your stylesheet needed to draw the part of the page that is visible before scrolling (the "above the fold"). web.dev defines it as the technique that "extracts the CSS for above-the-fold content in order to render content to the user as fast as possible". It helps because CSS is render-blocking : the browser will not paint a page until it has downloaded and parsed the stylesheets in the <head> (painting early would show a flash of unstyled content, FOUC: the raw HTML in browser-default styles for a moment before the real styles arrive), so one slow <link rel="stylesheet"> delays the first paint by the full download time. Putting the critical slice in a <style> block in the HTML "eliminates the need to make an additional request to fetch these styles", and the rest of the CSS loads without blocking. The downsides: the inlined bytes are sent again with every HTML response, they cannot be cached separately, a wrong or incomplete slice paints the first screen wrong, and the extraction is another build step that must be kept current. You ship it by extracting at several screen sizes during the build, inlining the result, and loading the full stylesheet asynchronously.
How the browser blocks, in order
The browser parses the HTML into the DOM (the tree of elements) and meets <link rel="stylesheet"> in the head.
It downloads the file and parses it into the CSSOM (the tree of style rules). Painting waits for this, because drawing before the styles arrive would flash unstyled content.
DOM and CSSOM combine into the render tree (only the visible elements, with their computed styles), then layout and paint happen.
First Contentful Paint (FCP) , the time at which the first text or image appears, therefore cannot come before the stylesheet has arrived. With critical CSS inlined in the HTML, step 2 needs no extra request for the first screen.
Measured: what inlining changes
The demo page is a small landing page (navigation, hero, six feature cards, price table, footer) with one 3,884 byte stylesheet that is delayed by 600 ms to imitate a slow network. For each of two screen sizes it loads the page twice: once with a normal blocking <link>, once with the extracted critical CSS inlined and the full stylesheet loaded with the preload pattern from web.dev. In plain words: <link rel="preload" as="style" href="/site.css"> tells the browser to download the file without applying it, which does not block painting; when the download finishes, the onload attribute runs this.rel='stylesheet', which turns the same link into a real stylesheet that applies instantly from the already downloaded file (this.onload=null stops the handler from running again); and <noscript><link rel="stylesheet" ...></noscript> is the fallback for visitors without JavaScript, who would otherwise never get the full CSS.
js
// critical-css-demo.mjs
import { chromium } from 'playwright';
import { gzipSync } from 'node:zlib';
const BREAK = process.env.BREAK || ''; // 'empty' ships no critical CSS; 'blocking' loads the full CSS with a blocking link
const CSS_DELAY = 600; // ms the stylesheet takes to arrive on the simulated slow network
// ---- a small landing page and its single stylesheet ----
const BODY = `
<header class="nav"><a class="logo" href="/">Acme</a><nav><a href="/a">Docs</a><a href="/b">Pricing</a></nav></header>
<section class="hero"><h1>Ship faster</h1><p class="lead">One platform for your whole team.</p><a class="btn btn-primary" href="/start">Start free</a></section>
<section class="features">${[1, 2, 3, 4, 5, 6].map((n) => `<article class="card"><h3>Feature ${n}</h3><p>Text for feature ${n}.</p></article>`).join('')}</section>
<section class="pricing"><table class="price-table"><tr><th>Plan</th><th>Cost</th></tr><tr><td>Pro</td><td>$9</td></tr></table></section>
<footer class="footer"><p>(c) Acme</p></footer>`;
const unused = ['modal', 'tooltip', 'carousel', 'tabs', 'accordion', 'toast', 'dropdown', 'pagination', 'breadcrumb', 'badge', 'spinner', 'avatar']
.map((c) => `.${c}{position:relative;padding:12px 16px;border:1px solid #ccd;border-radius:6px;background:#fff;box-shadow:0 2px 8px rgba(0,0,0,.15)}.${c}:hover{background:#f5f7ff}.${c} .${c}-title{font-weight:600;margin:0 0 8px}`).join('\n');
const CSS = `
:root{--brand:#0a58ca}
*{box-sizing:border-box}
body{margin:0;font:16px/1.5 system-ui,sans-serif;color:#222}
.nav{display:flex;justify-content:space-between;align-items:center;padding:16px 24px;background:#0b1f3a}
.nav a{color:#fff;margin-left:16px;text-decoration:none}.nav .logo{font-weight:700;margin:0}
.hero{padding:96px 24px;text-align:center;background:#eaf1ff}
.hero h1{font-size:56px;margin:0 0 12px}.lead{font-size:20px;margin:0 0 24px}
.btn{display:inline-block;padding:12px 24px;border-radius:6px;text-decoration:none}.btn-primary{background:var(--brand);color:#fff}.btn-primary:hover{background:#084298}
@media (max-width:600px){.hero{padding:48px 16px}.hero h1{font-size:32px}.nav nav{display:none}}
.features{display:grid;grid-template-columns:repeat(3,1fr);gap:24px;padding:64px 24px}
@media (max-width:600px){.features{grid-template-columns:1fr}}
.card{padding:24px;border:1px solid #dde;border-radius:8px}.card h3{margin:0 0 8px}
.pricing{padding:64px 24px}.price-table{border-collapse:collapse;width:100%}.price-table td,.price-table th{border:1px solid #ccd;padding:8px}
.footer{padding:32px 24px;background:#0b1f3a;color:#9ab}
${unused}`;
// ---- extraction: keep a rule if one of its selectors matches an element in the first screen ----
const KEYS_FN = `() => {
// An element hidden by a rule (display:none) still counts: use its nearest displayed ancestor's box,
// because the rule that hides it must be in the critical CSS or it flashes on first paint.
const visible = (el) => { while (el && getComputedStyle(el).display === 'none') el = el.parentElement;
const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.top < innerHeight && r.bottom > 0; };
const hits = (rule) => rule.selectorText.split(',').some((sel) => {
const base = sel.trim().replace(/::?(hover|focus|active|before|after)\\b/g, '') || '*';
try { return [...document.querySelectorAll(base)].some(visible); } catch { return false; } });
const keys = []; const sheet = document.styleSheets[0];
[...sheet.cssRules].forEach((r, i) => {
if (r.type === CSSRule.STYLE_RULE && hits(r)) keys.push(String(i));
if (r.type === CSSRule.MEDIA_RULE && matchMedia(r.conditionText).matches)
[...r.cssRules].forEach((q, j) => { if (hits(q)) keys.push(i + '.' + j); });
});
return keys;
}`;
const BUILD_FN = `(keys) => {
const sheet = document.styleSheets[0], out = [];
[...sheet.cssRules].forEach((r, i) => {
if (keys.includes(String(i))) out.push(r.cssText);
if (r.type === CSSRule.MEDIA_RULE) { const inner = [...r.cssRules].filter((q, j) => keys.includes(i + '.' + j)).map((q) => q.cssText);
if (inner.length) out.push('@media ' + r.conditionText + '{' + inner.join('') + '}'); }
});
return out.join('\\n');
}`;
// ---- pages ----
const head = (critical, mode) => mode === 'external'
? `<link rel="stylesheet" href="/site.css">`
: `<style>${critical}</style>
<link rel="${BREAK === 'blocking' ? 'stylesheet' : 'preload'}" as="style" href="/site.css" onload="this.onload=null;this.rel='stylesheet';window.restLoaded=1">
<noscript><link rel="stylesheet" href="/site.css"></noscript>`;
const SNAP = `<script>
window.snap = () => [...document.querySelectorAll('body *')].filter((el) => { const r = el.getBoundingClientRect(), s = getComputedStyle(el);
return s.display !== 'none' && r.top < innerHeight && r.bottom > 0; })
.map((el) => { const s = getComputedStyle(el); return el.tagName + ':' + [s.display, s.fontSize, s.color, s.backgroundColor, s.padding, Math.round(el.getBoundingClientRect().height)].join('|'); });
window.atPaint = new Promise((res) => new PerformanceObserver((l, o) => { if (l.getEntriesByName('first-contentful-paint').length) { res({ fcp: l.getEntriesByName('first-contentful-paint')[0].startTime, styles: window.snap() }); o.disconnect(); } })
.observe({ type: 'paint', buffered: true }));
</script>`;
const html = (critical, mode) => `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${head(critical, mode)}${BODY}${SNAP}`;
const browser = await chromium.launch();
const fail = [];
const check = (ok, msg) => { console.log((ok ? 'PASS ' : 'FAIL ') + msg); if (!ok) fail.push(msg); };
async function open(size, page_html) {
const ctx = await browser.newContext({ viewport: size }); const page = await ctx.newPage();
await page.route('http://site.test/**', async (route) => {
const p = new URL(route.request().url()).pathname;
if (p === '/site.css') { await new Promise((r) => setTimeout(r, CSS_DELAY)); return route.fulfill({ contentType: 'text/css', body: CSS }); }
route.fulfill({ contentType: 'text/html', body: page_html });
});
await page.goto('http://site.test/page', { waitUntil: 'commit' });
return { ctx, page };
}
const SIZES = { phone: { width: 375, height: 667 }, desktop: { width: 1280, height: 800 } };
// 1. Extract: run the full page at each size and union the matching rules.
const keyset = {};
for (const [name, size] of Object.entries(SIZES)) {
const { ctx, page } = await open(size, html('', 'external'));
await page.waitForLoadState('load');
keyset[name] = await page.evaluate(`(${KEYS_FN})()`);
await ctx.close();
}
const union = [...new Set([...keyset.phone, ...keyset.desktop])];
const buildCss = async (keys) => { const { ctx, page } = await open(SIZES.desktop, html('', 'external')); await page.waitForLoadState('load');
const css = await page.evaluate(`(${BUILD_FN})(${JSON.stringify(keys)})`); await ctx.close(); return css; };
const criticalAll = BREAK === 'empty' ? '' : await buildCss(union);
const criticalDesktopOnly = await buildCss(keyset.desktop);
const gz = (s) => gzipSync(s).length;
console.log(`full css: ${CSS.length} bytes (${gz(CSS)} gzip); critical css: ${criticalAll.length} bytes (${gz(criticalAll)} gzip); rules kept: ${union.length}`);
// 2. Measure first contentful paint and what the first paint looks like, per size.
for (const [name, size] of Object.entries(SIZES)) {
const run = async (critical, mode) => {
const { ctx, page } = await open(size, html(critical, mode));
await page.waitForFunction(() => window.atPaint); // the inline script has run
const first = await page.evaluate(() => window.atPaint); // FCP time and styles at that moment
if (mode !== 'external') await page.waitForFunction(() => window.restLoaded === 1, null, { timeout: 15000 }).catch(() => {});
else await page.waitForLoadState('load');
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
const final = await page.evaluate(() => window.snap()); await ctx.close();
return { fcp: Math.round(first.fcp), same: JSON.stringify(first.styles) === JSON.stringify(final) };
};
const ext = await run('', 'external'), inl = await run(criticalAll, 'inline');
console.log(name.padEnd(8), 'FCP external =', ext.fcp, 'ms FCP inline critical =', inl.fcp, 'ms');
check(ext.fcp >= CSS_DELAY, `${name}: with a blocking stylesheet the first paint waits for the ${CSS_DELAY} ms download`);
check(inl.fcp < CSS_DELAY, `${name}: with critical CSS inlined the first paint does not wait for the stylesheet`);
check(inl.same, `${name}: what is painted first already has its final styles and size`);
}
// 3. A critical set taken only at desktop size is wrong on a phone.
const dOnly = await (async () => {
const { ctx, page } = await open(SIZES.phone, html(criticalDesktopOnly, 'inline'));
await page.waitForFunction(() => window.atPaint);
const first = await page.evaluate(() => window.atPaint);
await page.waitForFunction(() => window.restLoaded === 1); await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
const final = await page.evaluate(() => window.snap()); await ctx.close();
return JSON.stringify(first.styles) !== JSON.stringify(final);
})();
check(dOnly, 'a critical set extracted only at desktop width changes appearance on a phone when the rest arrives');
// 4. The cost: the inline bytes ride along on every HTML response, a cached stylesheet does not.
const htmlBytes = (c, m) => Buffer.byteLength(html(c, m));
const views = 3;
const externalTotal = htmlBytes('', 'external') * views + CSS.length; // CSS downloaded once, then cached
const inlineTotal = htmlBytes(criticalAll, 'inline') * views + CSS.length; // critical repeated in each HTML, rest cached
console.log(`bytes over ${views} page views: external = ${externalTotal}, inline critical + cached rest = ${inlineTotal}, extra = ${inlineTotal - externalTotal}`);
check(inlineTotal - externalTotal >= criticalAll.length * views, 'inlining costs at least the critical CSS size on every page view');
await browser.close();
process.exit(fail.length ? 1 : 0);
Run in the Playwright 1.48.2 container (Chromium, headless), one sample run prints:
text
full css: 3884 bytes (837 gzip); critical css: 1193 bytes (544 gzip); rules kept: 19
phone FCP external = 629 ms FCP inline critical = 31 ms
PASS phone: with a blocking stylesheet the first paint waits for the 600 ms download
PASS phone: with critical CSS inlined the first paint does not wait for the stylesheet
PASS phone: what is painted first already has its final styles and size
desktop FCP external = 621 ms FCP inline critical = 28 ms
PASS desktop: with a blocking stylesheet the first paint waits for the 600 ms download
PASS desktop: with critical CSS inlined the first paint does not wait for the stylesheet
PASS desktop: what is painted first already has its final styles and size
PASS a critical set extracted only at desktop width changes appearance on a phone when the rest arrives
bytes over 3 page views: external = 9386, inline critical + cached rest = 13427, extra = 4041
PASS inlining costs at least the critical CSS size on every page view
exit=0
How the extraction code works: KEYS_FN runs inside the page at one screen size. It walks the top-level rules of the stylesheet (document.styleSheets[0].cssRules; each CSSRule has a selectorText such as .hero h1, .lead). For each rule it splits the selector list at commas, removes pseudo-classes such as :hover that cannot be looked up on their own, and asks querySelectorAll for the matching elements; the rule is kept if any match is visible, meaning it has a non-zero box that starts above the bottom edge of the viewport (an element hidden with display: none is judged by its nearest displayed ancestor). For @media rules, it first checks with matchMedia that the condition applies at this screen size, then does the same for the rules inside. It returns the positions of the kept rules as keys such as 3 or 7.2 (rule 2 inside the media rule at position 7). BUILD_FN turns a list of keys back into CSS text, re-wrapping kept media rules in @media ... { }. The main script runs KEYS_FN at phone and desktop size and builds the critical CSS from the union of the keys. snap records computed display, font size, colours, padding and height for every visible element, so the test can compare the first paint with the final page.
Reading the sample: the blocking page's first paint arrives just after the 600 ms the stylesheet takes, while the inline page paints in a few tens of milliseconds because nothing it needs is still in flight. The check "what is painted first already has its final styles and size" compares computed style and height of every element in the first screen at the moment of the first paint against the same elements after the full stylesheet has loaded: they are identical, so nothing jumps when the rest arrives. The critical slice is 1,193 of 3,884 bytes (19 rules); the rest of the file holds components that never appear on this page's first screen.
Extracting it for a real site
Per page template, not per URL. A template is one page layout that many URLs share (every product page uses the same product template). Run the extraction for each distinct layout (home, article, product), against a representative page, in the build or CI.
At several screen sizes, and union the results. The demo extracts at 375 x 667 and 1280 x 800 and keeps a rule if either size needs it. The test proves why: a set taken at desktop size only changed the phone's appearance when the full stylesheet arrived. Tools such as Critical document the same need: it "supports extracting critical CSS for multiple screen resolutions". web.dev also lists criticalCSS and Penthouse.
Count hidden elements. An extractor that skips elements with display: none drops the phone rule .nav nav { display: none }, and the first paint then shows a navigation that the final CSS hides (the first-paint style check fails in exactly that case). The extractor above judges a hidden element by its nearest visible ancestor, so a rule that hides something in the first screen stays in the critical set.
Keep @font-face (the rule that declares a web font and where to download it) and the variables the first screen uses (the :root rule is kept above because it matches the page's root element). If the first screen uses a web font, preload the font file it needs and keep its @font-face rule in the slice.
Handle content that appears later. Elements added by JavaScript, cookie banners and A/B variants are invisible to a snapshot taken from static HTML. Extract from a page in the state users first see, or add their rules to a force-include list.
Load the rest without blocking. The pattern in the demo follows web.dev: "link rel="preload" as="style" requests the style sheet asynchronously" and the onload attribute "lets the browser process the CSS when the style sheet finishes loading", with <noscript> as the fallback for visitors without JavaScript. Keep the full file under a content-hashed name (the file name includes a fingerprint of its contents, such as site.3fa9c1.css, so a new deploy gets a new name) so the browser can keep it with a long Cache-Control: max-age and never re-download an unchanged file.
Gate it in CI. Re-run the extraction on every build, and fail the build if the critical set exceeds a size budget or if the first-paint style check above fails.
Downsides, with numbers from the same run
Cost What happens Measured or derived in the run Repeated bytes the inlined CSS is part of every HTML response and "prevents the browser from caching the CSS for reuse on subsequent page loads" (web.dev) Over 3 page views, the external stylesheet design transferred 9,386 bytes of HTML plus CSS and the inline design 13,427, 4,041 more: the page overhead is paid each view while the stylesheet is cached Size ceiling web.dev's guidance is to "keep above-the-fold content under 14 KB (compressed)" the demo's critical CSS is 544 bytes compressed (gzip) Delayed HTML inlining a large amount "delays the transmission of the rest of the HTML document" grows with the critical set Maintenance risk an out-of-date slice paints the first screen wrong, then corrects the desktop-only extraction in the run Duplicate rules the full stylesheet repeats the critical rules. This is harmless: the inline block comes first and the full file, which contains every rule in its original order, loads later, so wherever the two disagree the full file wins and the result is exactly what the full file alone would give the demo loads the whole file as the async part
web.dev also describes it as "an advanced performance technique that can improve performance, but can also lead to bugs", and says most sites should be able to reach its recommended performance targets without it. A sensible rule: use it when the measured First Contentful Paint (or Largest Contentful Paint) is held back by a blocking stylesheet request, and the first screen's CSS is small enough to inline.
For a marketing landing page
A landing page is the best case: one template, a large first-screen hero, and a lot of CSS for below-the-fold sections. Extract once per template at phone and desktop sizes, inline the slice, preload the one web font the headline uses, and load the remaining stylesheet asynchronously. If the page is server-rendered, do the extraction at build time and inline from a template variable so every server response carries it.
Running the code
bash
mkdir demo && cd demo && echo '{"type":"module"}' > package.json
npm i playwright@1.48.2 # save the listing above as critical-css-demo.mjs
docker run --rm -v "$PWD":/w -w /w mcr.microsoft.com/playwright:v1.48.2-jammy sh -c 'node critical-css-demo.mjs; echo exit=$?'
# add -e BREAK=empty (no critical CSS) or -e BREAK=blocking (blocking load) to run broken: FAIL lines and exit=1
Want to create your own tailored preparation guide using our deep research ? Get Started for Free Visual-first, interactive, structured learning paths
Browse Frontend Developer jobs AI-enriched listings across hundreds of company career pages
Explore Jobs InterviewStack.io Master your next tech interview with AI-driven mock interviews, curated question banks, and proctored challenges. Share your best transcripts and get noticed by recruiters.
© 2026 InterviewStack.io. All rights reserved.