27 questions
Frontend performance interview questions and answers
Junior questions
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. 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-vitalswith 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 fixedmin-heightoraspect-ratio, even when no ad arrives. For the font, pick a fallback with close metrics viasize-adjustandascent-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
transformdoes not?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:
srcsetwith several widths andsizes, 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 withloading="lazy", so the browser loads them while scrolling. The main photo, on the contrary, must not be lazy and getsfetchpriority="high", because right now it competes with 59 others. Give every<img>awidthandheightto avoid layout shifts. In Next.jsnext/imagedoes most of this.Follow-up question: How do you serve images from a CDN so the format is chosen by the
Acceptheader without breaking the cache?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,
preloadthe critical file withcrossorigin,font-display: swap(text immediately in a system font) oroptional(no swap if it did not arrive in time). For the fallback, tune metrics withsize-adjustandascent-overrideso the swap does not shift text. In Next.jsnext/fontdoes this.Follow-up question: When is
font-display: optionalbetter thanswap?- Knowledge base: Next.js
- Source: developer.mozilla.org
Middle questions
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
lazyalso defers it until layout. So first the photo must be in the server HTML as a plain<img>withoutlazyand withfetchpriority="high", or with apreloadin<head>. Next the size: AVIF or WebP,srcsetandsizes, 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 viaweb-vitalswith 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?
- Knowledge base: Next.js, Cloudflare
- Source: web.dev
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 (
useTransitionoruseDeferredValuein React, orscheduler.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. 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@importin 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@importadds another sequential request after the first file loads. A synchronous<script>withoutdeferorasyncstops HTML parsing until it is downloaded and executed. Fix: replace@importwith a<link>pluspreconnectto 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 itasyncwith a short anti flicker timeout. Check the chains in the Network tab and on the WebPageTest waterfall.Follow-up question: When is
deferbetter thanasync, and what changes for a script that reads the DOM?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
lazystarts 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, oneSuspenseat page level, and warm up the next routes withimport()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. 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-visualizerfor 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 orlodash-es), two versions of the same dependency, modules with side effects and nosideEffects: false. Fix: a lighter library or the nativeIntl.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. 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.jsdoes 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, getsno-cache(it may be stored but must be revalidated every time viaETag) or a shortmax-age, so users get new links quickly. On the CDN, HTML getss-maxagewithstale-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-storethe right choice for HTML rather thanno-cache?- Knowledge base: Cloudflare
- Source: web.dev
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: privateorno-store, and the CDN must not cache responses withSet-Cookieor 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 viaVaryor CDN settings.Follow-up question: How do you invalidate the CDN cache after a deploy?
- Knowledge base: Cloudflare
- Source: developer.mozilla.org
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
loadevent 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?
- Knowledge base: Next.js
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-rowcountandaria-rowindexand manage focus during keyboard navigation. For variable heights, rows are measured after rendering (measureElementin TanStack Virtual) instead of using a fixed estimate. With a few thousand rows, pagination orcontent-visibility: autois simpler: it skips rendering of offscreen blocks but keeps them in the DOM for search.Follow-up question: How does
content-visibility: autohelp without a library?- Knowledge base: React
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
postMessagecopies 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 asTransferable: anArrayBuffermoves without a copy and the sender loses access to it, or the worker reads theFiledirectly. 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
SharedArrayBuffera better fit for such a task, and what do you need to configure on the server for it?- Knowledge base: Vite, webpack
- Source: developer.mozilla.org
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 thansetTimeout(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 tosetTimeout. 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. 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
preloadfetches 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.preloadmakes sense only for what the first screen needs but the browser discovers late: a font referenced from CSS, an LCP photo inserted by JS.prefetchfetches resources for the next navigation at low priority,preconnectdoes DNS, TCP and TLS ahead of time to one or two critical domains, anddns-prefetchdoes only DNS. Confirm every hint by measuring before and after.Follow-up question: How does
fetchprioritycomplement preload?- Knowledge base: Next.js, Cloudflare
- Source: developer.mozilla.org
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?
- Knowledge base: Cloudflare
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
Applevel. What do you do?Answer
Every change of a context value re-renders all its consumers, and a provider at the
Applevel with a newvalueobject 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 memoizevalue. The table getsmemowith stable props, and filtering is deferred withuseDeferredValueso 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
childrenkeep a component from re-rendering?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-vitalslibrary 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 withnavigator.sendBeacononvisibilitychangeso 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?
- Knowledge base: Next.js
- Source: github.com
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
transformfaster than animatingleft?
Senior questions
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
revalidateor 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 throughSuspense, 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. 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,
Suspensegives 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. 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,
setIntervalor WebSocket handlers not cleaned up on unmount, detached DOM nodes referenced from JS, data caches without a limit. Fixes: cleanup inuseEffect,AbortControllerfor 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?
- Knowledge base: React
- Source: developer.chrome.com
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. 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.waitingand show an Update button, after consent callskipWaiting()and reload the page, becauseskipWaitingwithout a reload mixes old HTML with new files. Version the caches and delete old ones inactivate, 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?
- Knowledge base: Cloudflare, Vite
- Source: developer.mozilla.org
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
- JavaScript interview questions and answers
- TypeScript interview questions and answers
- React interview questions and answers
- Node.js interview questions and answers
- NestJS interview questions and answers
- HTTP interview questions and answers
- SQL interview questions and answers
- Databases interview questions and answers
- Git interview questions and answers
- Algorithms interview questions and answers
- System design interview questions and answers
- DevOps interview questions and answers
- Web security interview questions and answers
- Testing interview questions and answers