Skip to main content

Стратегії завантаження ресурсів - preload, prefetch, modulepreload

Стратегії завантаження ресурсів — це <link rel> підказки (resource hints), які повідомляють браузеру, коли і як завантажувати ресурси до того, як вони реально знадобляться сторінці. Preload забирає критичні ресурси з високим пріоритетом у момент виявлення підказки. Prefetch ставить ресурси майбутньої сторінки в чергу з найнижчим пріоритетом. Modulepreload завчасно завантажує граф ES-модулів без виконання жодного коду.

Теорія

TL;DR

  • Аналогія: preload = шеф-кухар бере інгредієнти, потрібні прямо зараз (високий пріоритет). prefetch = поповнення комори для завтрашнього рецепта (фон, тільки під час простою). modulepreload = підготовка модульних інгредієнтів без початку готування.
  • Різниця в пріоритеті: preload запускається з тим самим пріоритетом, що й CSS, вставлений парсером. prefetch отримує найнижчий слот і виконується лише коли черга вільна. modulepreload має високий пріоритет, але тільки для ES-модулів.
  • Правило вибору: зображення LCP або критичний шрифт → preload. Ресурс наступної сторінки → prefetch. Точка входу ES-модуля та весь граф імпортів → modulepreload.
  • Важливо: preload без атрибута as Chrome ігнорує з повідомленням "Did not assign resource type". Preload шрифту без crossorigin дає промах кешу при кожному завантаженні.

Швидкий приклад

html
<head> <!-- Високий пріоритет, спрацьовує при виявленні, як і CSS --> <link rel="preload" href="/hero.avif" as="image" fetchpriority="high"> <!-- Шрифт потребує crossorigin, інакше браузер завантажить його двічі --> <link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin> <!-- Найнижчий пріоритет, виконується у фоні під час простою --> <link rel="prefetch" href="/checkout.js"> <!-- Завантажує app.mjs та всі його імпорти паралельно, без виконання --> <link rel="modulepreload" href="/app.mjs"> </head> <body> <img src="/hero.avif" alt="Hero"> <!-- Вже в кеші, без мерехтіння --> <script type="module" src="/app.mjs"></script> <!-- Залежності готові --> </body>

Зображення-герой відображається менш ніж за 100ms. Шрифт завантажується без спалаху невидимого тексту (FOIT). Імпорти модулів беруться з кешу замість послідовного ланцюжка мережевих запитів.

Ключова різниця

Preload — це високопріоритетний запит, який спрацьовує при парсингу <head>, ще до того, як браузер знайшов би ресурс у звичайному потоці. Prefetch — спекуляція під час простою: браузер завантажує ресурс лише тоді, коли немає нічого важливішого, і зберігає його для майбутньої навігації. Modulepreload орієнтований саме на ES-модулі. Він завантажує вхідний модуль, рекурсивно знаходить граф import-залежностей і завантажує все паралельно, але V8 не парсить і не виконує жодного з модулів на цьому етапі. Ти отримуєш повну мережеву перевагу без навантаження на основний потік до моменту, коли реально виконається тег <script type="module">.

Коли використовувати

  • Зображення LCP або відео вище лінії згину → preload з fetchpriority="high"
  • Веб-шрифт, що використовується в критичному CSS → preload з as="font" і crossorigin
  • JavaScript, який блокує перший рендер → preload з as="script"
  • CSS вище лінії згину → preload з as="style"
  • Ресурс на сторінці, куди користувач, найімовірніше, перейде → prefetch
  • Точки входу ES-модулів та весь граф імпортів → modulepreload
  • Сторонні скрипти, потрібні, але не термінові → prefetch
  • Не використовуй більше 4-5 preload на сторінці: вони конкурують між собою за пропускну здатність.

Таблиця порівняння

preloadprefetchmodulepreload
ПріоритетВисокий (як CSS)Найнижчий (тільки простій)Високий (тільки модулі)
Коли спрацьовуєОдразу при виявленніКоли браузер вільнийДо тега <script>
Блокує рендерНіНіНі
Що завантажуєБудь-який тип (потрібен as)Будь-який типJS-модулі + граф імпортів
Виконує кодНіНіНі
Основний кейсЗображення LCP, шрифтНавігація на наступну сторінкуSPA та ES-модульні застосунки
Потрібен crossoriginДля шрифтів — такНіЗалежить від CORS-політики
Коли використовуватиКритичний шлях заразСпекулятивне майбутнєСучасні JS-застосунки

Як браузер обробляє це зсередини

Коли браузер натрапляє на <link rel="preload"> під час парсингу <head>, Chromium ставить запит у чергу MEDIUM або HIGH у LoadingScheduler::Priority — той самий рівень, що й таблиці стилів, вставлені парсером. Prefetch отримує рівень LOWEST і виконується тільки після того, як вся робота з вищим пріоритетом завершена.

Modulepreload (Chrome 66+, всі основні браузери з 2023 року) використовує внутрішній ModuleGraph браузера. Він запускає fetch() для вхідного модуля, парсить import-вирази, знаходить залежності та завантажує все паралельно. V8 пропускає етапи парсингу й виконання. Це усуває класичний каскад модулів: без modulepreload граф з 3 рівнями глибини потребує 3 послідовних мережевих round trip-и до початку виконання коду. З ним все завантажується в один паралельний пакет.

Safari 16.4 і раніше підтримував лише частину специфікації без рекурсивного розв'язання залежностей. Повна підтримка з'явилася в Safari 17.0.

preload на практиці

Атрибут as обов'язковий. Без нього Chrome ігнорує підказку та виводить попередження в консоль. Браузеру потрібен as, щоб визначити правильні заголовки запиту та зв'язати попередньо завантажений ресурс з тим, хто його пізніше запросить.

html
<!-- Правильно: браузер знає тип, ключ кешу збігається --> <link rel="preload" href="/api-data.json" as="fetch" crossorigin> <!-- Браузер ігнорує. При пізнішому запиті кеш не спрацює. --> <link rel="preload" href="/api-data.json">

Для шрифтів crossorigin — не побажання, а вимога. Шрифти завантажуються через CORS. Preload без crossorigin і фактичний запит шрифту використовують різні ключі кешу. Шрифт завантажується двічі, що зводить нанівець усю ідею.

prefetch для навігації

Prefetch добре працює, коли намір користувача передбачуваний. На сторінці товару — prefetch скрипту кошика. На сторінці входу — prefetch бандла дашборду. Браузер завантажує у фоні і ніколи не конкурує з поточною сторінкою.

html
<!-- Сторінка товару: користувач, швидше за все, піде в кошик --> <link rel="prefetch" href="/cart.js"> <link rel="prefetch" href="/cart.css"> <!-- Список статей: користувач клацне по статті --> <link rel="prefetch" href="/article-renderer.js">

Варто пам'ятати: service worker перехоплює prefetch-запити так само, як і будь-який інший fetch. Якщо у твого SW є cache-first стратегія для цього URL, ресурс може прийти з кешу SW, а не з мережі.

modulepreload для ES-модульних застосунків

Без modulepreload завантаження ES-модульного застосунку відбувається послідовно: браузер завантажує app.mjs, парсить, знаходить import { utils } from './utils.mjs', завантажує utils.mjs, парсить, знаходить наступний імпорт і так далі. Граф з 3 рівнями — це 3 послідовних round trip до запуску жодного рядка коду.

html
<!-- Без modulepreload: послідовні запити, ~600ms на 3G --> <script type="module" src="/app.mjs"></script> <!-- З modulepreload: все завантажується паралельно при виявленні --> <link rel="modulepreload" href="/app.mjs"> <link rel="modulepreload" href="/utils.mjs"> <link rel="modulepreload" href="/api.mjs"> <script type="module" src="/app.mjs"></script>

Vite робить це автоматично. У продакшн-збірці Vite генерує <link rel="modulepreload"> для кожного чанка в графі модулів, перетворюючи каскад на один паралельний пакет запитів.

preconnect і dns-prefetch

Ці дві підказки працюють на рівні з'єднання, а не ресурсу. Корисні для сторонніх origin-ів, до яких ти точно звернешся.

preconnect робить ранє рукостискання: DNS-запит, TCP-з'єднання і TLS-переговори. Це економить 100-500ms на першому запиті до того origin-у.

dns-prefetch вирішує тільки DNS. Дешевше, але й виграш менший (20-120ms). Використовуй для доменів, до яких можеш звернутися, але не гарантовано.

html
<!-- Fonts: ти точно звернешся сюди --> <link rel="preconnect" href="https://fonts.googleapis.com"> <!-- Аналітика: скоріш за все, але некритично --> <link rel="dns-prefetch" href="https://analytics.google.com">

Обмеж preconnect двома-трьома origin-ами. Кожен тримає відкрите TCP/TLS з'єднання, що коштує пам'яті. Більше трьох — і ти витрачаєш ресурси на з'єднання, які можуть закінчитися таймаутом до першого запиту.

Атрибут fetchpriority

Це окремий важіль від типу підказки (Chrome 101+). Він дозволяє підняти або опустити ресурс всередині його стандартного пріоритетного кошика.

html
<!-- Підняти LCP-зображення вище інших високопріоритетних ресурсів --> <img src="/hero.jpg" fetchpriority="high" loading="eager"> <!-- Опустити піксель відстеження поза критичним шляхом --> <img src="/tracking-pixel.jpg" fetchpriority="low"> <!-- Комбінація, яку використовує Next.js для Image з пропом priority --> <link rel="preload" href="/hero.jpg" as="image" fetchpriority="high">

Дані HTTP Archive показують покращення LCP на 30-50% при поєднанні preload і fetchpriority="high" для зображень вище лінії згину. Next.js застосовує цей шаблон автоматично для зображень з пропом priority.

Типові помилки

Відсутній as у preload

html
<!-- Chrome виводить "Did not assign resource type". Запит ігнорується. --> <link rel="preload" href="script.js"> <!-- Правильно --> <link rel="preload" href="script.js" as="script">

prefetch для критичного CSS поточної сторінки

html
<!-- Найнижчий пріоритет. CSS може прийти після рендеру. FOUC у продакшні. --> <link rel="prefetch" href="critical.css" as="style"> <!-- Правильно: якщо потрібно зараз, використовуй preload --> <link rel="preload" href="critical.css" as="style">

modulepreload для класичного скрипту

html
<!-- Нічого не робить для не-модульних скриптів. Залежності не вирішуються. --> <link rel="modulepreload" href="bundle.js"> <!-- Працює тільки разом з type="module" --> <link rel="modulepreload" href="app.mjs"> <script type="module" src="app.mjs"></script>

Preload шрифту без crossorigin

html
<!-- Промах кешу: preload і фактичний запит шрифту мають різні ключі. --> <link rel="preload" href="font.woff2" as="font"> <!-- Правильно --> <link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin>

Забагато preload одночасно

Преленти 8-10 ресурсів потрапляють в одну високопріоритетну чергу і конкурують між собою та з CSS, що блокує рендер. Тримай кількість preload в межах 4-5 на сторінку.

Де зустрічається

  • Next.js: автоматично додає <link rel="preload"> для зображень з пропом priority та для _next/static/css чанків, з fetchpriority="high" для LCP-зображень.
  • Vite: у продакшн-збірці генерує <link rel="modulepreload"> для кожного чанка в графі модулів.
  • React 18 streaming SSR: вставляє modulepreload-підказки у HTML-потік для залежностей гідратації.
  • Lighthouse: аудит "Preload key requests" позначає LCP-ресурси без preload.
  • Chrome DevTools, вкладка Network: колонка Priority показує High для цілей preload і Low для кандидатів на prefetch.

Питання на співбесіді

Q: Яка різниця між пріоритетами preload і prefetch у планувальнику Chromium?
A: Preload потрапляє на рівень MEDIUM/HIGH у LoadingScheduler::Priority, той самий, що й CSS від парсера. Prefetch — на рівень LOWEST і виконується лише коли черга вільна.

Q: Чи блокує preload парсинг HTML?
A: Ні. Запит виконується паралельно з парсингом. Якщо ти навмисно затримуєш рендер через обробник onload на елементі preload — це твій свідомий вибір, а не автоматична блокування.

Q: Яка різниця між modulepreload і dns-prefetch?
A: modulepreload завантажує повний вміст модулів і вирішує весь граф імпортів, наповнюючи кеш модулів реальним вмістом. dns-prefetch тільки перетворює ім'я хоста на IP-адресу. Жодного контенту не завантажується.

Q: Якщо preloaded ресурс повертає 404, чи отруює це кеш?
A: Ні. Кожен наступний запит іде в мережу заново. Можна додати обробник події error до елемента preload-посилання, щоб відловити збій і підставити запасне джерело — корисно в SPA-роутерах з динамічними шляхами до ресурсів.

Q: Чи взаємодіє preload з service worker?
A: Так, service worker перехоплює preload-запити, як і будь-який fetch. Якщо у SW є cache-first стратегія для того URL, ресурс прийде з кешу SW, а не з мережі.

Q: Ти в продакшні, потрібен максимальний LCP. Який повний шаблон?
A: Поєднуй preload + fetchpriority="high" + loading="eager" на елементі LCP-зображення. Потім вимірюй через PerformanceObserver з типом largest-contentful-paint. На повільних з'єднаннях пропуск цієї комбінації додає ~1 секунду до TTI для сторінок із зображеннями. HTTP Archive показує покращення LCP на 30-50% для зображень вище лінії згину.

Приклади

Базовий: preload, prefetch і modulepreload на одній сторінці

html
<!DOCTYPE html> <html> <head> <!-- Preload: високий пріоритет, спрацьовує при парсингу head --> <link rel="preload" href="/hero.avif" as="image" fetchpriority="high"> <link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin> <!-- Prefetch: фоновий запит під час простою --> <link rel="prefetch" href="/checkout.js"> <!-- Modulepreload: app.mjs та його імпорти завантажуються паралельно --> <link rel="modulepreload" href="/app.mjs"> <link rel="modulepreload" href="/utils.mjs"> </head> <body> <!-- hero.avif вже в кеші, відображається одразу --> <img src="/hero.avif" alt="Hero image" loading="eager"> <!-- Залежності модуля закешовані, без каскаду --> <script type="module" src="/app.mjs"></script> <!-- При кліці checkout.js вже в кеші --> <a href="/checkout">Оформити замовлення</a> </body> </html>

Зображення-герой знаходиться в кеші до того, як браузер дійде до тега <img>. Шрифт завантажується без спалаху невидимого тексту. Граф модулів вирішується з кешу приблизно за 10ms замість 3 послідовних round trip-ів.

Середній рівень: продакшн-налаштування Next.js (оптимізація LCP)

Це шаблон, який Next.js генерує для сторінки з hero-зображенням і власним шрифтом.

html
<head> <!-- LCP-зображення: preload + fetchpriority — шаблон Next.js Image з priority --> <link rel="preload" href="/hero.avif" as="image" fetchpriority="high"> <!-- Шрифт: crossorigin обов'язковий, type прискорює визначення MIME --> <link rel="preload" href="/_next/static/fonts/inter.woff2" as="font" type="font/woff2" crossorigin> <!-- Критичний CSS-чанк зі збірки Next.js --> <link rel="preload" href="/_next/static/css/app.css" as="style"> <!-- Prefetch ймовірного наступного маршруту --> <link rel="prefetch" href="/_next/static/chunks/dashboard.js"> </head>

Без комбінації preload + fetchpriority LCP-зображення конкурує з іншими ресурсами. З нею Lighthouse показує LCP 0.8s проти 2.5s без. CLS залишається на рівні 0, бо шрифт готовий до першого рендеру тексту.

Просунутий: modulepreload з динамічними імпортами (усунення каскаду)

html
<head> <!-- Декларуємо всі модулі графа на етапі парсингу head --> <link rel="modulepreload" href="/app.mjs"> <link rel="modulepreload" href="/utils.mjs"> <link rel="modulepreload" href="/api.mjs"> </head> <script type="module"> // Без modulepreload: // Round 1 - завантажити app.mjs, знайти utils.mjs + api.mjs // Round 2 - завантажити utils.mjs + api.mjs // Round 3 - виконати: ~600ms на 3G // З modulepreload вище: // Всі три завантажені паралельно при парсингу head // Динамічний імпорт вирішується з кешу за ~10ms import('./app.mjs').then(app => app.init()); </script>

Timeline у Chrome DevTools показує, що всі три модулі завантажуються паралельно з моменту парсингу <head>: 200ms загалом. Без modulepreload вони завантажуються послідовно після парсингу app.mjs: 600ms. Ці 400ms різниці відчутні як регресія TTI на повільних з'єднаннях і одразу видно в Lighthouse.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?