Frameworks performance
How JavaScript frameworks score across LCP, INP and CLS.
At a glance the headline numbers for Frameworks performance
How JavaScript frameworks score across LCP, INP and CLS.
htmx passes INP most often of any framework. 97.5% of its sites pass. Next.js passes least at 60.3%. jQuery (the most-used framework) sits at 94.5%. The gap is 37 percentage points.
The ranking sorted by INP at p75, fastest first
| # | Framework | INP p75 | Passing | Sites | |
|---|---|---|---|---|---|
| 1 | jQuery | 121ms | 94.5% | 129,597 | |
| 2 | React | 121ms | 92.2% | 8,487 | |
| 3 | htmx | 128ms | 97.5% | 268 | |
| 4 | Alpine.js | 135ms | 93.8% | 2,412 | |
| 5 | Vue | 144ms | 92.2% | 10,740 | |
| 6 | Svelte | 152ms | 89.8% | 416 | |
| 7 | Nuxt | 172ms | 84.7% | 1,162 | |
| 8 | Angular | 190ms | 79.2% | 3,036 | |
| 9 | Gatsby | 214ms | 68.9% | 197 | |
| 10 | Next.js | 271ms | 60.3% | 1,624 |
Passing INP per framework which group passes the INP most often
97% of htmx sites pass INP. Next.js trails 37 points behind, leaving 40% of its sites failing. computed
All five vitals at once the whole category without toggling - cell is the pass rate, small number the p75
One row per framework, one column per vital - the cell is the share of sites passing, the small number the p75. No toggling needed to see where the category actually differs.
htmx leads on INP: 97% of its sites pass. Next.js trails at 60%. computed
Why this matters for the Core Web Vitals, and where to start fixing it
A JavaScript framework shapes how much code runs in the browser to put your page on the screen. Some render on the server and ship light; others send a large bundle and rebuild the page on the client, which lands on the main thread and shows up in INP. The framework is not destiny, but its defaults and how you use them decide how much JavaScript the visitor has to pay for.
Start by looking at what ships to the browser and when it runs. Render on the server where you can, split the bundle so each page only loads what it needs, and defer the work that is not required for the first interaction. Framework JavaScript runs on the main thread, so the less of it the browser has to execute, the better INP gets.
How does your framework affect Core Web Vitals?
Among the JavaScript frameworks, jQuery reaches a good INP on 94.5% of sites; Next.js on 60.3%.
Chrome field data from 189,915 sites, representing millions of real page loads. How we measured.
Live queries (3) — admin only
SELECT 1 AS ok FROM site_frameworks LIMIT 0;
SELECT CAST(f.framework AS VARCHAR) AS e, COUNT(*) AS sites,
COUNT(*) FILTER (WHERE s.crux."all".lcp IS NOT NULL) AS all_lcp_n,
quantile_disc(s.crux."all".lcp, 0.5) AS all_lcp_median,
quantile_disc(s.crux."all".lcp, 0.75) AS all_lcp_p75,
COUNT(*) FILTER (WHERE s.crux."all".lcp <= 2500) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."all".lcp IS NOT NULL), 0) AS all_lcp_good,
COUNT(*) FILTER (WHERE s.crux."all".lcp > 2500 AND s.crux."all".lcp <= 4000) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."all".lcp IS NOT NULL), 0) AS all_lcp_ni,
COUNT(*) FILTER (WHERE s.crux."all".lcp > 4000) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."all".lcp IS NOT NULL), 0) AS all_lcp_poor,
COUNT(*) FILTER (WHERE s.crux."all".inp IS NOT NULL) AS all_inp_n,
quantile_disc(s.crux."all".inp, 0.5) AS all_inp_median,
quantile_disc(s.crux."all".inp, 0.75) AS all_inp_p75,
COUNT(*) FILTER (WHERE s.crux."all".inp <= 200) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."all".inp IS NOT NULL), 0) AS all_inp_good,
COUNT(*) FILTER (WHERE s.crux."all".inp > 200 AND s.crux."all".inp <= 500) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."all".inp IS NOT NULL), 0) AS all_inp_ni,
COUNT(*) FILTER (WHERE s.crux."all".inp > 500) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."all".inp IS NOT NULL), 0) AS all_inp_poor,
COUNT(*) FILTER (WHERE s.crux."all".cls IS NOT NULL) AS all_cls_n,
quantile_disc(s.crux."all".cls, 0.5) AS all_cls_median,
quantile_disc(s.crux."all".cls, 0.75) AS all_cls_p75,
COUNT(*) FILTER (WHERE s.crux."all".cls <= 0.1) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."all".cls IS NOT NULL), 0) AS all_cls_good,
COUNT(*) FILTER (WHERE s.crux."all".cls > 0.1 AND s.crux."all".cls <= 0.25) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."all".cls IS NOT NULL), 0) AS all_cls_ni,
COUNT(*) FILTER (WHERE s.crux."all".cls > 0.25) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."all".cls IS NOT NULL), 0) AS all_cls_poor,
COUNT(*) FILTER (WHERE s.crux."all".fcp IS NOT NULL) AS all_fcp_n,
quantile_disc(s.crux."all".fcp, 0.5) AS all_fcp_median,
quantile_disc(s.crux."all".fcp, 0.75) AS all_fcp_p75,
COUNT(*) FILTER (WHERE s.crux."all".fcp <= 1800) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."all".fcp IS NOT NULL), 0) AS all_fcp_good,
COUNT(*) FILTER (WHERE s.crux."all".fcp > 1800 AND s.crux."all".fcp <= 3000) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."all".fcp IS NOT NULL), 0) AS all_fcp_ni,
COUNT(*) FILTER (WHERE s.crux."all".fcp > 3000) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."all".fcp IS NOT NULL), 0) AS all_fcp_poor,
COUNT(*) FILTER (WHERE s.crux."all".ttfb IS NOT NULL) AS all_ttfb_n,
quantile_disc(s.crux."all".ttfb, 0.5) AS all_ttfb_median,
quantile_disc(s.crux."all".ttfb, 0.75) AS all_ttfb_p75,
COUNT(*) FILTER (WHERE s.crux."all".ttfb <= 800) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."all".ttfb IS NOT NULL), 0) AS all_ttfb_good,
COUNT(*) FILTER (WHERE s.crux."all".ttfb > 800 AND s.crux."all".ttfb <= 1800) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."all".ttfb IS NOT NULL), 0) AS all_ttfb_ni,
COUNT(*) FILTER (WHERE s.crux."all".ttfb > 1800) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."all".ttfb IS NOT NULL), 0) AS all_ttfb_poor,
COUNT(*) FILTER (WHERE s.crux."phone".lcp IS NOT NULL) AS phone_lcp_n,
quantile_disc(s.crux."phone".lcp, 0.5) AS phone_lcp_median,
quantile_disc(s.crux."phone".lcp, 0.75) AS phone_lcp_p75,
COUNT(*) FILTER (WHERE s.crux."phone".lcp <= 2500) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."phone".lcp IS NOT NULL), 0) AS phone_lcp_good,
COUNT(*) FILTER (WHERE s.crux."phone".lcp > 2500 AND s.crux."phone".lcp <= 4000) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."phone".lcp IS NOT NULL), 0) AS phone_lcp_ni,
COUNT(*) FILTER (WHERE s.crux."phone".lcp > 4000) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."phone".lcp IS NOT NULL), 0) AS phone_lcp_poor,
COUNT(*) FILTER (WHERE s.crux."phone".inp IS NOT NULL) AS phone_inp_n,
quantile_disc(s.crux."phone".inp, 0.5) AS phone_inp_median,
quantile_disc(s.crux."phone".inp, 0.75) AS phone_inp_p75,
COUNT(*) FILTER (WHERE s.crux."phone".inp <= 200) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."phone".inp IS NOT NULL), 0) AS phone_inp_good,
COUNT(*) FILTER (WHERE s.crux."phone".inp > 200 AND s.crux."phone".inp <= 500) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."phone".inp IS NOT NULL), 0) AS phone_inp_ni,
COUNT(*) FILTER (WHERE s.crux."phone".inp > 500) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."phone".inp IS NOT NULL), 0) AS phone_inp_poor,
COUNT(*) FILTER (WHERE s.crux."phone".cls IS NOT NULL) AS phone_cls_n,
quantile_disc(s.crux."phone".cls, 0.5) AS phone_cls_median,
quantile_disc(s.crux."phone".cls, 0.75) AS phone_cls_p75,
COUNT(*) FILTER (WHERE s.crux."phone".cls <= 0.1) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."phone".cls IS NOT NULL), 0) AS phone_cls_good,
COUNT(*) FILTER (WHERE s.crux."phone".cls > 0.1 AND s.crux."phone".cls <= 0.25) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."phone".cls IS NOT NULL), 0) AS phone_cls_ni,
COUNT(*) FILTER (WHERE s.crux."phone".cls > 0.25) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."phone".cls IS NOT NULL), 0) AS phone_cls_poor,
COUNT(*) FILTER (WHERE s.crux."phone".fcp IS NOT NULL) AS phone_fcp_n,
quantile_disc(s.crux."phone".fcp, 0.5) AS phone_fcp_median,
quantile_disc(s.crux."phone".fcp, 0.75) AS phone_fcp_p75,
COUNT(*) FILTER (WHERE s.crux."phone".fcp <= 1800) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."phone".fcp IS NOT NULL), 0) AS phone_fcp_good,
COUNT(*) FILTER (WHERE s.crux."phone".fcp > 1800 AND s.crux."phone".fcp <= 3000) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."phone".fcp IS NOT NULL), 0) AS phone_fcp_ni,
COUNT(*) FILTER (WHERE s.crux."phone".fcp > 3000) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."phone".fcp IS NOT NULL), 0) AS phone_fcp_poor,
COUNT(*) FILTER (WHERE s.crux."phone".ttfb IS NOT NULL) AS phone_ttfb_n,
quantile_disc(s.crux."phone".ttfb, 0.5) AS phone_ttfb_median,
quantile_disc(s.crux."phone".ttfb, 0.75) AS phone_ttfb_p75,
COUNT(*) FILTER (WHERE s.crux."phone".ttfb <= 800) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."phone".ttfb IS NOT NULL), 0) AS phone_ttfb_good,
COUNT(*) FILTER (WHERE s.crux."phone".ttfb > 800 AND s.crux."phone".ttfb <= 1800) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."phone".ttfb IS NOT NULL), 0) AS phone_ttfb_ni,
COUNT(*) FILTER (WHERE s.crux."phone".ttfb > 1800) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."phone".ttfb IS NOT NULL), 0) AS phone_ttfb_poor,
COUNT(*) FILTER (WHERE s.crux."desktop".lcp IS NOT NULL) AS desktop_lcp_n,
quantile_disc(s.crux."desktop".lcp, 0.5) AS desktop_lcp_median,
quantile_disc(s.crux."desktop".lcp, 0.75) AS desktop_lcp_p75,
COUNT(*) FILTER (WHERE s.crux."desktop".lcp <= 2500) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."desktop".lcp IS NOT NULL), 0) AS desktop_lcp_good,
COUNT(*) FILTER (WHERE s.crux."desktop".lcp > 2500 AND s.crux."desktop".lcp <= 4000) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."desktop".lcp IS NOT NULL), 0) AS desktop_lcp_ni,
COUNT(*) FILTER (WHERE s.crux."desktop".lcp > 4000) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."desktop".lcp IS NOT NULL), 0) AS desktop_lcp_poor,
COUNT(*) FILTER (WHERE s.crux."desktop".inp IS NOT NULL) AS desktop_inp_n,
quantile_disc(s.crux."desktop".inp, 0.5) AS desktop_inp_median,
quantile_disc(s.crux."desktop".inp, 0.75) AS desktop_inp_p75,
COUNT(*) FILTER (WHERE s.crux."desktop".inp <= 200) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."desktop".inp IS NOT NULL), 0) AS desktop_inp_good,
COUNT(*) FILTER (WHERE s.crux."desktop".inp > 200 AND s.crux."desktop".inp <= 500) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."desktop".inp IS NOT NULL), 0) AS desktop_inp_ni,
COUNT(*) FILTER (WHERE s.crux."desktop".inp > 500) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."desktop".inp IS NOT NULL), 0) AS desktop_inp_poor,
COUNT(*) FILTER (WHERE s.crux."desktop".cls IS NOT NULL) AS desktop_cls_n,
quantile_disc(s.crux."desktop".cls, 0.5) AS desktop_cls_median,
quantile_disc(s.crux."desktop".cls, 0.75) AS desktop_cls_p75,
COUNT(*) FILTER (WHERE s.crux."desktop".cls <= 0.1) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."desktop".cls IS NOT NULL), 0) AS desktop_cls_good,
COUNT(*) FILTER (WHERE s.crux."desktop".cls > 0.1 AND s.crux."desktop".cls <= 0.25) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."desktop".cls IS NOT NULL), 0) AS desktop_cls_ni,
COUNT(*) FILTER (WHERE s.crux."desktop".cls > 0.25) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."desktop".cls IS NOT NULL), 0) AS desktop_cls_poor,
COUNT(*) FILTER (WHERE s.crux."desktop".fcp IS NOT NULL) AS desktop_fcp_n,
quantile_disc(s.crux."desktop".fcp, 0.5) AS desktop_fcp_median,
quantile_disc(s.crux."desktop".fcp, 0.75) AS desktop_fcp_p75,
COUNT(*) FILTER (WHERE s.crux."desktop".fcp <= 1800) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."desktop".fcp IS NOT NULL), 0) AS desktop_fcp_good,
COUNT(*) FILTER (WHERE s.crux."desktop".fcp > 1800 AND s.crux."desktop".fcp <= 3000) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."desktop".fcp IS NOT NULL), 0) AS desktop_fcp_ni,
COUNT(*) FILTER (WHERE s.crux."desktop".fcp > 3000) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."desktop".fcp IS NOT NULL), 0) AS desktop_fcp_poor,
COUNT(*) FILTER (WHERE s.crux."desktop".ttfb IS NOT NULL) AS desktop_ttfb_n,
quantile_disc(s.crux."desktop".ttfb, 0.5) AS desktop_ttfb_median,
quantile_disc(s.crux."desktop".ttfb, 0.75) AS desktop_ttfb_p75,
COUNT(*) FILTER (WHERE s.crux."desktop".ttfb <= 800) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."desktop".ttfb IS NOT NULL), 0) AS desktop_ttfb_good,
COUNT(*) FILTER (WHERE s.crux."desktop".ttfb > 800 AND s.crux."desktop".ttfb <= 1800) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."desktop".ttfb IS NOT NULL), 0) AS desktop_ttfb_ni,
COUNT(*) FILTER (WHERE s.crux."desktop".ttfb > 1800) * 1.0 / NULLIF(COUNT(*) FILTER (WHERE s.crux."desktop".ttfb IS NOT NULL), 0) AS desktop_ttfb_poor
FROM site_frameworks f
JOIN sites s ON s.origin = f.origin
WHERE f.framework IS NOT NULL AND CAST(f.framework AS VARCHAR) <> ''
GROUP BY f.framework;
SELECT COUNT(*) AS n FROM sites;