Как я к этому пришёл
Причин было три, и каждая по отдельности казалась мелочью.
Первая — обработчик скролла. Он выполнялся на каждое событие, а браузер шлёт их десятками в секунду. Внутри я читал getBoundingClientRect, и каждое такое чтение заставляло браузер пересчитывать раскладку страницы. IntersectionObserver делает то же самое, но силами самого браузера и без блокировки главного потока.
Вторая — анимация неправильных свойств. Меняешь top — браузер пересчитывает геометрию всей страницы. Меняешь transform — задача уходит на видеокарту, и главный поток свободен.
Третья — частицы. Я поставил фиксированное число, посчитанное для своего экрана. На телефоне столько же точек с попарным соединением линиями давало квадратичный рост вычислений. Привязал количество к площади и поставил потолок.
И отдельно: вкладка в фоне продолжала рисовать. Одна подписка на visibilitychange — и в фоне не тратится ни кадра.
Сайт остаётся плавным на среднем телефоне, батарея не греется, а при системной настройке «уменьшить движение» анимации просто выключаются.