27 questions

Frontend performance interview questions and answers

Junior questions

  1. 1. Search Console flagged mobile product pages as "Poor" on Core Web Vitals, while Lighthouse on your laptop gives 95. Which metrics and thresholds does Google look at, and why does the lab score not disprove the complaint?

    Answer

    Core Web Vitals are three metrics from real visits: LCP (when the largest element appears, good up to 2.5 s), INP (delay from an interaction to the next frame, good up to 200 ms, replaced FID in March 2024) and CLS (sum of unexpected layout shifts, good up to 0.1). Google evaluates them at the 75th percentile of CrUX field data, separately for mobile and desktop. Lighthouse runs one synthetic visit on your hardware and network, and INP cannot be measured in the lab at all without real interactions. So first you find which metric is bad in the field and on which page templates, then reproduce it with CPU and network throttling.

    Follow-up question: Why does Lighthouse show TBT instead of INP, and how closely are the two related?

  2. 2. After a release CLS on articles went from 0.05 to 0.3. The release added a cookie consent banner at the top of the page, a new web font and an ad slot between paragraphs. How do you find what exactly shifts the layout, and how do you fix each one?

    Answer

    First find the shifts: the Performance panel in DevTools shows layout shifts with their elements, and web-vitals with attribution reports the largest shift in the field. A banner inserted at the top that pushes content down becomes an overlay (position: fixed) or gets reserved space. The ad slot gets a fixed min-height or aspect-ratio, even when no ad arrives. For the font, pick a fallback with close metrics via size-adjust and ascent-override, so the swap does not change line heights. Shifts within 500 ms after a user click or key press are not counted in CLS.

    Follow-up question: Why does animating a block's height cause CLS while animating with transform does not?

  3. 3. A gallery of 60 photos at 3 MB each: on mobile the page pulls over 100 MB, and the main photo appears last. What do you fix in the <img> markup?

    Answer

    Serve a size that fits the screen: srcset with several widths and sizes, so a phone does not download a 4000 pixel original, and a modern format, AVIF or WebP (via an image CDN or <picture>). Mark photos below the first screen with loading="lazy", so the browser loads them while scrolling. The main photo, on the contrary, must not be lazy and gets fetchpriority="high", because right now it competes with 59 others. Give every <img> a width and height to avoid layout shifts. In Next.js next/image does most of this.

    Follow-up question: How do you serve images from a CDN so the format is chosen by the Accept header without breaking the cache?

  4. 4. On a slow mobile connection the page text appears only after several seconds, and when it does, the lines jump. The font is loaded from Google Fonts with a <link>. What is happening and what do you change?

    Answer

    Until the web font loads, the browser hides the text (up to 3 seconds in Chrome), and after the swap a font with different metrics changes line heights, so the layout shifts. The font also comes from third party domains, so first there are DNS, TCP and TLS to two hosts. Fix: self host it as WOFF2 subset to the alphabets you need, preload the critical file with crossorigin, font-display: swap (text immediately in a system font) or optional (no swap if it did not arrive in time). For the fallback, tune metrics with size-adjust and ascent-override so the swap does not shift text. In Next.js next/font does this.

    Follow-up question: When is font-display: optional better than swap?

Middle questions

  1. 1. LCP on a product page on mobile is 4.2 s with a TTFB of 600 ms. The main photo is rendered by a carousel component after hydration and has loading="lazy". What do you change, in what order, and how do you confirm it helped?

    Answer

    LCP splits into four parts: TTFB, the delay before the resource starts loading, the load time and the render delay, and here the second is the largest: the browser learns about the photo only after JS runs, and lazy also defers it until layout. So first the photo must be in the server HTML as a plain <img> without lazy and with fetchpriority="high", or with a preload in <head>. Next the size: AVIF or WebP, srcset and sizes, so a phone does not pull the desktop image. Then TTFB through HTML caching on the CDN. You verify in DevTools Performance (the LCP breakdown by phase) and in field data via web-vitals with attribution, not with a single Lighthouse run.

    Follow-up question: What changes if the LCP element on mobile is not a photo but a large heading in a web font?

  2. 2. INP on a catalogue page is 450 ms. The worst interaction is a click on a filter checkbox, after which 5000 products are filtered and the list re-renders. How do you find which part of the delay is largest, and what do you do?

    Answer

    INP consists of input delay (the main thread is busy with something else), handler time and presentation delay (rendering and painting the frame). The Performance panel or the Long Animation Frames API shows which phase is long and which scripts run in it. Here the handler and rendering are the likely culprits: first update the checkbox itself immediately, and defer the filtering and list re-render (useTransition or useDeferredValue in React, or scheduler.yield() between steps). Then reduce the work itself: memoize the filtered list, virtualize, filter in a Web Worker for large data. Check the result with field data and attribution, because INP comes from close to the worst interaction of a visit.

    Follow-up question: Why can INP be bad even if your click handler runs in 5 ms?

  3. 3. A landing page shows a blank screen for 2.5 s on 4G, although the HTML arrives in 300 ms. <head> has three CSS files, Google Fonts loaded through @import in CSS, and a synchronous A/B testing script. What blocks the first render and how do you untangle it?

    Answer

    The browser paints nothing until it builds the CSSOM, so all CSS in <head> blocks rendering, and @import adds another sequential request after the first file loads. A synchronous <script> without defer or async stops HTML parsing until it is downloaded and executed. Fix: replace @import with a <link> plus preconnect to the font domain, or self host, merge and shrink the CSS, inline the critical CSS of the first screen and load the rest deferred. Move the A/B script to server side variant assignment, or at least make it async with a short anti flicker timeout. Check the chains in the Network tab and on the WebPageTest waterfall.

    Follow-up question: When is defer better than async, and what changes for a script that reads the DOM?

  4. 4. The initial SPA bundle is 1.8 MB because the admin area, the chart editor and reports are in one chunk. You wrapped almost every component in React.lazy, and now navigating to the reports page shows three spinners in a row. Where should the split boundaries be, and how do you avoid this waterfall?

    Answer

    Split by routes and by heavy, rarely used parts (the editor, charts, the export modal), not by every component: a nested lazy starts loading only after its parent renders, so small chunks load one after another. Hence three spinners. Fix: one chunk per route, shared dependencies in a vendor chunk, one Suspense at page level, and warm up the next routes with import() on hover or when idle. Page data should start loading in parallel with the code, not after it renders (a router loader or query prefetch). Verify with a bundle visualizer and the Network tab.

    Follow-up question: What does a user see if an old chunk disappeared from the server after a deploy while their tab is still open, and how do you handle it?

  5. 5. After adding one date formatting function the bundle grew by 300 KB gzip. How do you find the cause and what do you do about it?

    Answer

    A bundle visualizer (rollup-plugin-visualizer for Vite, webpack-bundle-analyzer, @next/bundle-analyzer) shows what ended up in the chunk and how big it is. Typical causes: a library pulls in every locale (moment), a CommonJS package without tree shaking (import _ from 'lodash' instead of a per function import or lodash-es), two versions of the same dependency, modules with side effects and no sideEffects: false. Fix: a lighter library or the native Intl.DateTimeFormat, per function imports, the ESM build, deduplicating versions in the lockfile. To catch this before merge, add a size limit in CI (size-limit) and look at compressed size, not raw.

    Follow-up question: In a monorepo with several apps, how do you prevent two copies of React in a bundle?

  6. 6. After a deploy some users see the old version of the site for another day, and for others the page breaks: a cached old app.js does not match the new HTML, because the file name does not change. How do you configure caching for HTML and static assets so both problems go away?

    Answer

    The root cause is that assets without a hash in the name get cached, and the browser mixes old and new code. The build must emit names with a content hash (app.3f9a.js), then they can be cached forever: Cache-Control: public, max-age=31536000, immutable, and a new version is a new URL. HTML, on the contrary, gets no-cache (it may be stored but must be revalidated every time via ETag) or a short max-age, so users get new links quickly. On the CDN, HTML gets s-maxage with stale-while-revalidate, and the cache is purged on deploy. Old chunks stay on the server for a few more days, because open tabs load them lazily.

    Follow-up question: When is no-store the right choice for HTML rather than no-cache?

  7. 7. You put a CDN in front of the site and enabled caching of all responses for an hour to take load off the origin. The next day a user complains they saw someone else's cart. What went wrong, and what can a CDN cache safely?

    Answer

    A CDN caches a response by a key, usually the URL, and does not know the page is personalised through a cookie. The first user cached their cart, and the next ones got a copy of it. Personalised responses must send Cache-Control: private or no-store, and the CDN must not cache responses with Set-Cookie or requests carrying an auth token. Safe to cache: hashed static assets, images, fonts and public HTML with a short TTL, with personal parts loaded by a separate request. If a response depends on a header (language, format), that header has to be in the cache key via Vary or CDN settings.

    Follow-up question: How do you invalidate the CDN cache after a deploy?

  8. 8. Marketing added a chat, two pixels and a heatmap through a tag manager, after which INP went from 150 to 400 ms and LCP got a second worse. How do you prove they are the cause, and how do you limit their impact without switching everything off?

    Answer

    You prove it by measuring: the Performance panel and Long Animation Frames show which scripts occupy the main thread, Lighthouse has a section on third party code, and WebPageTest lets you block domains and compare. Third party scripts run on the main thread, so they add input delay and compete for the network with LCP resources. Limits: load them after the load event or after the first interaction, show the chat as a facade (a button that loads the widget only on click), send pixels from the server, move heavy work to a Web Worker (Partytown). Then agree with marketing on a budget and regular tag audits, because every tag must have an owner and a cost.

    Follow-up question: How do you reduce the impact of a tag manager that holds dozens of tags?

  9. 9. A log table with 20,000 rows takes 4 seconds to render and scrolling stutters. You turned on virtualization, and QA files bugs: Ctrl+F does not find rows, a screen reader sees 30 rows out of 20,000, and variable height rows jump. How do you solve this, or which approach do you choose instead of virtualization?

    Answer

    Virtualization keeps only the visible rows plus a margin in the DOM and replaces the rest with spacers, so rendering and scrolling no longer depend on 20,000 rows. The side effects are expected: Ctrl+F searches only the DOM, so you need your own search over the data, and for screen readers you set aria-rowcount and aria-rowindex and manage focus during keyboard navigation. For variable heights, rows are measured after rendering (measureElement in TanStack Virtual) instead of using a fixed estimate. With a few thousand rows, pagination or content-visibility: auto is simpler: it skips rendering of offscreen blocks but keeps them in the DOM for search.

    Follow-up question: How does content-visibility: auto help without a library?

  10. 10. Parsing a 50 MB CSV in the browser freezes the UI for 8 seconds. You moved it to a Web Worker: the UI no longer freezes, but total time went up and the tab's memory doubled. Why, and how should data be passed?

    Answer

    postMessage copies data with structured clone: the 50 MB string going in and the array of objects coming back are copied in full, and receiving the result blocks the main thread again. Large binary data is passed as Transferable: an ArrayBuffer moves without a copy and the sender loses access to it, or the worker reads the File directly. Send back only what is needed: aggregates, a page of rows or typed arrays, not 500 thousand objects. Better still, parse as a stream (file.stream()) and send results in parts. A worker has no DOM access, so rendering stays on the main thread.

    Follow-up question: When is SharedArrayBuffer a better fit for such a task, and what do you need to configure on the server for it?

  11. 11. The Performance panel shows a 900 ms task after clicking Apply: a loop processes 10,000 records. How do you split it so the UI stays responsive, and why is scheduler.yield() better than setTimeout(0)?

    Answer

    A task longer than 50 ms counts as long: while it runs the browser does not handle input and does not paint frames. Split the work into chunks by time (a few milliseconds, not a fixed number of records) and yield between them. setTimeout(0) puts the continuation at the end of the task queue, so any other script, third party included, can get in first, and deeply nested timers also get a minimum 4 ms delay. scheduler.yield() also yields for input and rendering, but the continuation gets priority over other tasks, and where the API is not available yet you keep a fallback to setTimeout. If the work is pure computation, it is better moved to a Web Worker.

    Follow-up question: How do you decide that a chunk of work has run long enough to yield?

  12. 12. Someone added <link rel="preload"> for 12 resources "so everything loads faster", and LCP got worse, while the console says some preloads were not used within a few seconds. Why, and when do preload, prefetch and preconnect actually help?

    Answer

    preload fetches a resource with high priority for the current page, so 12 of them compete for bandwidth with the LCP image and critical CSS, and the most important thing arrives later. The console warning means the resource was not needed right away, so the bytes were wasted. preload makes sense only for what the first screen needs but the browser discovers late: a font referenced from CSS, an LCP photo inserted by JS. prefetch fetches resources for the next navigation at low priority, preconnect does DNS, TCP and TLS ahead of time to one or two critical domains, and dns-prefetch does only DNS. Confirm every hint by measuring before and after.

    Follow-up question: How does fetchpriority complement preload?

  13. 13. The build still concatenates all code into one bundle.js, and static assets are served from three subdomains (domain sharding), because once "it was faster". The server and CDN already run HTTP/2 and HTTP/3. Which of these tricks now hurt, and why?

    Answer

    HTTP/1.1 had a limit of about six connections per domain, so files were concatenated and served from several domains. HTTP/2 multiplexes many requests over one connection and compresses headers, so sharding now hurts: each subdomain needs its own DNS, TCP and TLS and breaks prioritisation within one connection. One big bundle is also a bad deal: changing one line invalidates the whole cache, and users download the code of every page. Sensible route based chunks and stable vendor chunks are better. HTTP/3 over QUIC removes head of line blocking at the TCP level and sets up connections faster, so the gain is largest on lossy mobile networks.

    Follow-up question: Why are overly small chunks bad even on HTTP/2?

  14. 14. Typing in the header search box lags. React Profiler shows that every keystroke re-renders the whole page with a 500 row table, because the search value lives in a context at the App level. What do you do?

    Answer

    Every change of a context value re-renders all its consumers, and a provider at the App level with a new value object touches the whole page. First I move state down to where it is needed: the search box keeps its text locally, and the table receives only the result. If a context is needed, I split it into separate contexts for the value and for the setters and memoize value. The table gets memo with stable props, and filtering is deferred with useDeferredValue so typing stays fast. With React Compiler some manual memoization goes away, but the shape of state still matters.

    Follow-up question: How does composition via children keep a component from re-rendering?

  15. 15. CrUX shows poor INP for the whole site but does not say on which pages, devices and buttons. Your Lighthouse is green. How do you collect your own field data to find the cause, and what exactly do you send?

    Answer

    Lab tools (Lighthouse, WebPageTest) give one reproducible visit, and CrUX only an aggregate of real users per origin or URL, so the cause is visible in neither. Your own RUM: the web-vitals library in its attribution build sends each metric with context: page template, device type, network, and for INP also the target element, the event type and scripts from Long Animation Frames. Send with navigator.sendBeacon on visibilitychange so data is not lost when the tab closes. Analyse the 75th percentile by segment, and reproduce the problematic scenario you found in the lab with CPU throttling.

    Follow-up question: Why do field data analyses look at the 75th percentile rather than the median?

  16. 16. A page loads 600 KB of CSS from a UI library, of which a few percent is used, and the first render waits for this file. How do you reduce render blocking by styles?

    Answer

    All CSS in <head> blocks rendering, because the browser does not paint without the CSSOM, so 600 KB is a direct delay of the first frame, especially on mobile. First remove what is unused: import only the library components you need, utility CSS with template scanning (Tailwind), PurgeCSS for legacy code, carefully with classes added dynamically. Then split styles by route, inline the critical CSS of the first screen into the HTML and load the rest without blocking. Check with the Coverage tab in DevTools and the compressed CSS size.

    Follow-up question: Why is animating transform faster than animating left?

  17. 17. Navigation between catalogue pages in Next.js took 1.5 s with an empty screen. You turned on prefetch for every link, and mobile traffic per user tripled, while the server gets ten times more requests. How do you speed up navigation without that?

    Answer

    Prefetching every visible link on a catalogue page means hundreds of requests for code and data, most of which will never be used: that is user traffic and server load. Be selective: for long lists turn off automatic prefetch (prefetch={false} on Link) and do it on hover or pointerdown, only for the most likely transitions, and not under Save-Data or on a slow network. The transition itself gets faster with a data cache (TanStack Query, the router cache), an instant page shell with a skeleton through Suspense or loading.tsx, and HTML caching on the CDN. For ordinary multi page sites there is the Speculation Rules API with prefetch and prerender rules.

    Follow-up question: How do you measure that prefetch actually sped up navigation rather than just adding traffic?

Senior questions

  1. 1. A catalogue of 50,000 products is currently a CSR SPA with a 4 s LCP and weak indexing. Prices and stock change every minute. A colleague proposes SSG for the whole catalogue. Which pages would you render how, and why?

    Answer

    SSG for 50,000 pages means a long build and stale prices between builds, so it does not fit the whole catalogue. Product and category pages suit ISR or SSR with CDN caching: HTML is generated on the first request, served from cache and refreshed by revalidate or by a product change event (on demand revalidation). Prices and stock that must be accurate to the minute are loaded by a separate small request or streamed through Suspense, so the whole HTML does not become uncacheable. Static pages (delivery, about) stay SSG, while the cart and account are rendered on the client or with uncached SSR. Validate the choice by TTFB, LCP and origin load.

    Follow-up question: Why can an SSR page become interactive later than it appears?

  2. 2. An SSR page shows content in 1.2 s, but on a mid range Android the Buy button does not respond for another 3 seconds, and field INP is poor. What is happening, and how do you reduce it without giving up SSR?

    Answer

    After SSR the browser downloads all the page JS and renders the tree again to attach handlers: until hydration finishes, the page looks ready but clicks wait. On a weak CPU that is seconds of long tasks. Reduce the amount: Server Components in React leave static parts without client JS, Suspense gives selective hydration (React hydrates first the part the user interacts with), rarely used widgets hydrate lazily or when they enter the viewport. Islands architecture (Astro) hydrates only interactive islands. Separately, cut large serialized state from the HTML, because it gets parsed too.

    Follow-up question: What is resumability, and how does it differ from hydration?

  3. 3. A dashboard left open in a tab for a whole working day takes 2 GB after 6 hours, slows down and the tab crashes. How do you find the leak, and which causes do you check first?

    Answer

    I reproduce the scenario in a loop (switching screens, refreshing data) and take three heap snapshots in DevTools: before, after a few cycles and after a few more. Comparing snapshots by constructor shows what grows, and the Retainers chain leads to the root that holds the objects. I check the typical causes first: subscriptions and listeners that are never removed, setInterval or WebSocket handlers not cleaned up on unmount, detached DOM nodes referenced from JS, data caches without a limit. Fixes: cleanup in useEffect, AbortController for requests and listeners, a size limit and lifetime for the cache. After the fix I check that memory reaches a plateau instead of growing.

    Follow-up question: What are detached DOM nodes, and how do you find them?

  4. 4. Over six months the bundle grew from 180 to 420 KB and LCP slowly got worse, and nobody noticed, because each PR added only a few kilobytes. How do you build in control so this does not happen again, and what do you do when an important feature does not fit the budget?

    Answer

    A regression made of small steps is invisible without an automatic limit, so the budget is set in numbers: kilobytes of compressed JS per route, LCP and INP at the 75th percentile, the number of third party scripts. CI runs size-limit or similar on every PR and Lighthouse CI with thresholds, so a breach fails before merge, while RUM field data shows the trend for real users. The budget is agreed with product, because it is a trade off between features and speed. When an important feature does not fit, the options are: remove something else, load the feature lazily, or deliberately raise the budget with an owner and a deadline, rather than quietly turning the check off.

    Follow-up question: Who on the team may raise the budget, and how is that recorded?

  5. 5. After introducing a Service Worker with cache first for everything, some users stay on an old version for weeks, and after an API deploy with a new contract the app breaks for them. How do you set up caching strategies and worker updates?

    Answer

    Cache first for everything means that even HTML and API responses come from the cache, and a new worker activates only after every tab with the old one is closed, so users stay on the old version. Strategies are split by type: hashed static assets cache first, HTML network first or stale while revalidate, the API network first or not cached at all. Updates: detect a new version via registration.waiting and show an Update button, after consent call skipWaiting() and reload the page, because skipWaiting without a reload mixes old HTML with new files. Version the caches and delete old ones in activate, and keep the API backward compatible, because old clients always exist. For emergencies keep a kill switch: a worker that deletes the caches and unregisters itself.

    Follow-up question: How do you safely update a Service Worker for users?

  6. 6. Conversion on Android is 40% lower than on iPhone. The bundle is 450 KB gzip: on a MacBook the page is interactive in 1 s, on a budget Android in 9 s. A manager asks why, if the size is the same, the difference is so big. What do you answer and what do you do?

    Answer

    A byte of JS costs more than a byte of image: after download it has to be decompressed, parsed, compiled and executed on the main thread, and the CPU of a budget Android is several times slower than a MacBook. 450 KB gzip is about 1.5 MB of code, and on a weak device that is seconds of long tasks before the first interaction. Actions: measure on a real device or with CPU throttling in DevTools and in RUM by device class; ship less code (route splitting, no heavy libraries, a modern build without needless polyfills and ES5 transpilation); move work to the server (SSR, Server Components); defer what is not important. Confirm the link to conversion with data, not assumptions.

    Follow-up question: Why does tree shaking not remove code from packages that have side effects on import?

Train these questions

Study the cards, answer by text or voice and get an AI grade. Needs a signed in account.

Answer out loud and get graded

Here you get the questions with model answers. In the ShortLesson app you answer yourself, by text or voice, and AI grades the answer against the rubric and asks a follow-up, like a real interviewer.

The app is coming to the stores soon.

Other topics