Как мы ускорили 9-мегабайтный WebAssembly-модуль, не убрав ни байта
Recraft Studio — это веб-приложение, куда люди обычно приходят сгенерировать пачку картинок (и не только) и удобно поработать с ними на канвасе. Сам канвас живёт на самой важной и загруженной странице — странице проекта (а-ля редактор, эдитор и т. д.). Это огромная страница с кучей разных тулов, и в какой-то момент мы упёрлись в проблему: редактор долго грузится.
Четыре пул-реквеста спустя редактор грузится на 15–19% быстрее, причём одинаково на p50, p75 и p90.
На это ушло около 300 строк кода!
Самое интересное для меня — то, чего эти строки не сделали. Страница качает ровно те же байты, что и раньше: 16,75 МБ тогда, 16,71 МБ сейчас.
Ничего не удалили и ничего не сжали. Изменилось только одно: момент, когда начинает грузиться самый большой файл. Этот файл — наш движок рендеринга: собственный форк Skia, скомпилированный в WebAssembly, 9,2 МБ по сети.
И ускорение я взял не с графика «до/после».
Изменения прошли как полноценный A/B-тест с контрольной группой: около 18 тысяч пользователей в каждой группе, одни и те же пять дней.
Воронка показала 8,5 секунды на p75
Началось всё с довольно очевидной задачи: я собирался доработать наши кастомные performance-метки. Внёс изменения, добавил пару логов, и стало интересно посмотреть на цифры.
Открыл Amplitude, собрал несколько графиков и удивился (неприятно, к сожалению). Около 8,5 секунды на p75 до момента, когда пользователь сможет сделать что-то полезное в редакторе. Никто не жаловался, инцидентов не было, просто часть людей привыкла, а часть отвалилась из-за «тормозов».
Вот что я измеряю:
- loading-state-shown — время до показа лоадера
- websocket-connected — время до соединения с нашим realtime-хранилищем
- room-data-synced — время до получения данных проекта
- skia-loaded — время до загрузки WASM-модуля
- engine-initialized — колбэк, который срабатывает, когда всё на месте: JS-чанки, WASM-модуль, всё остальное, и движок доступен в React
- canvas-rendered — время до первой отрисованной сцены на канвасе
- images-loaded — время до загрузки всех изображений проекта
Одна оговорка, чтобы правильно читать цифры: canvas-rendered и engine-initialized иногда идут как будто не в том порядке. Первая сцена рисуется вне React, а engine-initialized отправляется из useEffect. То есть событие срабатывает, когда до него доходит React, а не когда движок на самом деле готов.

Это была катастрофа.
Самый большой файл начинаем загружать позже, чем хотелось бы
Расскажу, как редактор грузился раньше.
Сначала, конечно, HTML и пачка JS-чанков. Когда они распарсены, происходит авторизация, подключение к realtime-хранилищу и ожидание, пока придут данные проекта. Потом запрашиваем базовые стили: без них редактор не работает. И только в самом конце отправляем запрос за WASM-модулем.
А как вы догадываетесь, он весьма огромный.
Но как мы тут оказались? Всё просто: импорт WASM-модуля лежит в ленивом чанке. Значит, браузер ничего не знает о его существовании, пока не загрузит и не распарсит кучу JS и не дождётся ответа со стилями.
Выглядело это, честно говоря, ужасно:

Сразу отвечу на очевидные вопросы вида:
- 9,2 МБ — это слишком дорого компилировать.
- 9,2 МБ — это просто слишком много.
Первое не проблема. Браузер начинает компилировать файл, как только получает первый байт: компиляция идёт потоково, параллельно со скачиванием, а повторная инициализация из кэша почти бесплатная. Это проблема не процессора, а сети.
Второе — реальная проблема, и о том, как мы её решили, я расскажу в следующей статье.
9 МБ, о которых браузер узнаёт слишком поздно
WASM-модуль — самый тяжёлый ресурс на странице, и его URL известен ещё до того, как выполнится первая строка нашего JS. Его можно было бы запросить сразу вместе с HTML. Но вместо этого запрос уходит только после трёх последовательных обращений к сети и обхода дерева React. Сейчас покажу, почему так получилось.
export const EngineProvider: FC<PropsWithChildren<Props>> = ({...}) => {
...
if (room.getStorageSnapshot() === null) {
room.connect()
throw room.getStorage()
}
...
}
Это первая часть проблемы. Здесь мы подключаемся к realtime-хранилищу и уходим в Suspense, пока не придут данные проекта. Всё, что находится ниже этого компонента, для браузера пока не существует.
Если копнуть глубже, найдётся ещё одна странность:
const Content: FC<Props> = ({ viewOnly = false }) => {
...
useConnectionDetect()
useLoadBasicStyleSuspense()
useGetCurrentUserStatistics()
...
}
Здесь то же самое: обход дерева React останавливается, пока не загрузятся данные. В трейсе это хорошо видно: запрос за базовыми стилями уходит через восемь миллисекунд после того, как пришли данные проекта. То есть он ждал не сеть, а своей очереди в дереве.
А ещё ниже находится вот это:
export const Stage = ({ children, ...props }: Props) => {
useKitInit()
useFontInit()
useInitSkiaWorker()
useProjectLoadTracker()(ProjectLoadStep.SkiaLoaded)
return <StageComponent {...props}>{children}</StageComponent>
}
Два хука подряд. Первый уходит в Suspense, поэтому второй просто не запускается. Получается, что мы ждём, пока загрузится 9,2-мегабайтный WASM-модуль, и только после этого начинаем запрашивать шрифт.
Теперь картина целиком:
- Парсинг стартовых JS-чанков
- Ожидание подключения к хранилищу проекта
- Ожидание базовых стилей
- Загрузка и инициализация CanvasKit
- Ожидание шрифтов
- Отрисовка первой сцены на канвасе
Шесть шагов, хотя URL был известен с самого начала.
Preload-подсказка и один вызов на уровне модуля
Теперь расскажу, как я решил это исправить.
Я начал с прелоада, это первое, что пришло мне в голову. Если мы знаем URL всех наших ресурсов, почему бы не вставить preload-ссылки в head страницы?
export const SkiaPreloadLinks = () => (
<NextHead>
{PRELOAD_HREFS.map(({ href, as, type, crossOrigin }) => (
<link
key={href}
rel="preload"
href={href}
as={as}
type={type}
crossOrigin={crossOrigin}
/>
))}
</NextHead>
)
Где PRELOAD_HREFS — это:
const PRELOAD_HREFS: PreloadHint[] = [
{
href: `${canvaskitUrl}/canvaskit.wasm`,
as: 'fetch',
crossOrigin: canvaskitUrl !== '' ? 'anonymous' : undefined,
},
{
href: DEFAULT_FONT_PRELOAD_URL,
as: 'fetch',
crossOrigin: 'anonymous',
}
]
Никакого обхода дерева React и ожидания других запросов. Вежливо просим браузер загрузить критичные ресурсы сразу.
Здесь есть одна деталь. DEFAULT_FONT_PRELOAD_URL — захардкоженная строка, и выглядит это некрасиво. Но если импортировать FontCache, чтобы получить URL по-нормальному, в бандл страницы проекта приехал бы fonts.json на 652 КБ. То есть прелоад съел бы собственный выигрыш.
Дальше я вынес вызов инициализации Skia на уровень модуля:
import { getInitialPresence, getInitialStorage } from '@utils/engine'
import { useProjectLoadTracker } from './hooks/useProjectLoadTracker'
initSkia()
type Props = {
viewOnly?: boolean
}
initSkia() запускает initKit() и initFont() параллельно и не дожидается их. Помните два хука в Stage, где первый уходил в Suspense, а второй так и не запускался? Теперь никто никого не ждёт.
Из-за этого пришлось переписать FontCache.initFont(). Раньше функция бросала промис для Suspense, а теперь это идемпотентный мемоизированный промис. Иначе ранний прогрев и путь через Suspense дрались бы за один и тот же fetch.
И последнее: запрос стилей я поднял как можно выше. Не в режиме Suspense: просто отправляем запрос, а обрабатываем его ниже по дереву React.
const Project = () => {
useLoadBasicStyle()
const t = useTranslations()
...
}
Четыре строки. К моменту, когда мы доходим до хука с Suspense, ответ либо уже в кэше, либо вот-вот придёт. В локальном трейсе этот запрос сдвигается примерно с 890 мс до 15 мс. Это примерные цифры с моей машины, а не с продакшена.

Теперь запрос за CanvasKit уходит в самой первой волне вместе с документом, примерно на 1,5 секунды раньше. Поэтому больше всего выигрывают самые медленные пользователи: эти 1,5 секунды приходились на бутстрап и авторизацию, и чем хуже соединение, тем больше эта фора.
Где я ошибся: preload vs prefetch
Сначала я упустил одну важную вещь: изменения выкатились сразу на две страницы.
/project/*— страница редактора;/projects— список проектов, точка входа на страницу проекта.
Поначалу это выглядело правильно, но не до конца, потому что на обеих страницах стоял один и тот же rel. Давайте посмотрим, какие значения rel нам подходят:
- preload — ресурс нужно загрузить сейчас, потому что он понадобится сразу на этой странице;
- prefetch — ресурс нужно загрузить, но не сейчас, а после критичных ресурсов, потому что он понадобится на следующей странице.
К сожалению, в первой версии я юзал preload, и на странице списка это привело к драке за ресурсы. -_- Справедливости ради, исправление оказалось достаточно тривиальным: prefetch на странице проектов и preload на странице редактора.
const Links = ({ rel }: { rel: 'preload' | 'prefetch' }) => {
...
return (
<NextHead>
{resourceHints.map(({ href, as, type, crossOrigin }) => (
<link
key={href}
rel={rel}
href={href}
as={as}
crossOrigin={crossOrigin}
type={type}
/>
))}
</NextHead>
)
}
export const SkiaPreloadLinks = () => <Links rel="preload" />
export const SkiaPrefetchLinks = () => <Links rel="prefetch" />
Эти изменения натолкнули меня на мысль о прогреве кэша JS-чанков при наведении на карточку проекта…
Три гипотезы из четырёх сработали
Что ж, карты на стол. Я запустил эксперимент с двумя группами: default и on.
Смотрим на цифры.
Шаги загрузки проекта, default → on:
| Шаг | p50 | p75 | p90 |
|---|---|---|---|
| Skia Loaded | −19% | −18% | −19% |
| Engine Initialized | −17% | −16% | −15% |
| Canvas Rendered | −18% | −17% | −17% |
| Images Loaded | −16% | −16% | −18% |
Чем пришлось заплатить — loading-state-shown, мс:
| default | on | Δ | |
|---|---|---|---|
| p50 | 409 | 429 | +20 (+5%) |
| p75 | 1250 | 1373 | +123 (+10%) |
| p90 | 3798 | 4132 | +335 (+9%) |
Как видно по таблице, мы улучшили несколько ключевых метрик. Для нас самые важные из них — skia-loaded, engine-initialized и canvas-rendered: именно в эти моменты пользователь может начать что-то делать на канвасе. Больше всего полегчало самым медленным ребятам: выигрыш растёт примерно с 670 мс на медиане до 1900 мс на p90. Быстрее стали все, конечно. Но медленные тут выиграли в лотерею.
Например, вот так выглядит skia-loaded, когда я включил эксперимент на всех:

Но на loading-state-shown мы немного просели, потому что на старте страницы стали грузить больше ресурсов. Это около 120 мс на p75, и просадка коснулась всех пользователей, независимо от устройства.
Три гипотезы из четырёх сработали, одна — нет. Я предполагал, что если поднимать сокет раньше, тайминги станут лучше. Что ж, я ошибся: боттлнеком было не соединение. Позже мой коллега Леонид решил это по-другому, и у него сработало, но это уже другая история.
Новый боттлнек: авторизация, подключение и данные проекта
Что ж, посмотрим, что именно происходит теперь, после оптимизации.

Видно, что CanvasKit начинает грузиться первым. То есть он загружается и просто лежит, пока не придёт код редактора и не начнёт его использовать.
Пока наш WASM-модуль ждёт своего звёздного часа, мы занимаемся кучей всего: авторизация → подключение по WebSocket → хэндшейк → получение данных проекта.
Ещё видно, что критичные для отрисовки данные тоже начинают грузиться гораздо раньше. Теперь мы не ждём просто так WASM-чанк!
Причём большая часть этого ожидания — вообще не работа процессора: главный поток почти всё время простаивает.
Самый тяжёлый ресурс страницы больше не находится на критическом пути.
В итоге боттлнек переехал, и теперь это сеть.
Префетч по ховеру: 631 → 193 мс до лоадера
Йоло! Вернёмся к прогреву кэша, о котором я говорил выше.
Раз уж мы просели на loading-state-shown, я решил это отыграть.
Во-первых, code splitting. Некоторые компоненты редактора в принципе не могут быть видны на первом кадре. У нас куча разных сайдбаров. Например, панель мокапов появляется только тогда, когда выделен мокап-слой. При инициализации выделение пустое, значит, этой панели там быть не может. Незачем тащить её в первый чанк.
Во-вторых, префетч. Можно начинать грузить чанк редактора, когда пользователь наводит курсор на карточку проекта: пока он дойдёт до клика, файл уже будет в пути. Стоит сказать, что Next.js и так это делает, но не до конца. В их логике есть нюанс: префетчатся только чанки, известные из самого роута, а вложенные динамические импорты внутри модуля не учитываются. Это как раз наш случай: чанк редактора подтягивает ещё один динамический чанк. Поэтому я сделал это сам, примерно так:
export const usePrefetchEditorOnHover = () => {
const called = useRef(false)
return useCallback(() => {
if (called.current) {
return
}
called.current = true
void import('@/components/pages/Editor')
}, [])
}
const handlePrefetchEditor = usePrefetchEditorOnHover()
const handleEnter = useCallback(() => {
setHover(true)
handlePrefetchEditor()
}, [handlePrefetchEditor])
onMouseEnter={handleEnter}
Code splitting выкатился на всех, а под флагом был только префетч. Так что эксперимент измеряет только прогрев по ховеру и ничего больше.
В результате получили огромный прирост:
loading-state-shown, default → on, мс:
| default | on | Δ | |
|---|---|---|---|
| p50 | 239 | 36 | −85% |
| p75 | 631 | 193 | −69% |
| p90 | 1680 | 708 | −58% |

В марте мы пожертвовали 123 мс на этой метрике, чтобы ускорить канвас. В апреле вернули 438 мс — справедливость восстановлена.
Что я из этого вынес
Что ж, пора заканчивать. Пара полезных выводов.
Собирать метрики капец как важно. Если хотите понимать свой продукт, как он работает и как на самом деле ведёт себя у пользователей, подумайте, какие сценарии и страницы наиболее необходимы. Это поможет найти слабые места в перформансе.
Это хорошо сработало для нас. Простенькая задачка про метрики натолкнула меня на мысль, что мы где-то что-то делаем не так.
В итоге после нескольких фиксов мы доставляем тот же объём данных на 17% быстрее.
Неплохо для 300 строк, которые не удалили ни байта.
В нашем случае боттлнек никуда не делся. Проблема с объёмом данных остаётся, но теперь она бьёт по пользователям меньше, чем раньше.
Что касается размера WASM-модуля: там я тоже много чего улучшил и с радостью расскажу, как это было, в следующей статье.