Data fetching и кэширование: пять уровней кэша
Кэширование в Next.js App Router — самая мощная и самая опасная часть фреймворка. Мощная, потому что из коробки тебе дают пять согласованных уровней кэша, которые в правильных руках превращают приложение в машину с TTFB около нуля. Опасная, потому что «пользователь видит старые данные» — самый частый продакшен-баг в Next.js, и лечится он пониманием, какой именно из пяти кэшей удержал данные.
В краткой версии ты видел опции fetch и пару функций ревалидации. Здесь соберём полную картину: как ведёт себя fetch по умолчанию, все способы управления свежестью, кэширование не-fetch данных через unstable_cache и дедупликацию запросов в рамках одного рендера.
Представь инцидент: редактор опубликовал акцию, прошло двадцать минут, на сайте старое. Клиент пишет в поддержку. Без карты кэшей ты будешь гадать; с картой — откроешь логи, найдёшь, какой слой отдал старое, и применишь точечное лечение за минуту.
fetch в RSC: поведение по умолчанию
Заголовок раздела «fetch в RSC: поведение по умолчанию»В серверных компонентах fetch — не браузерный fetch. Это обёртка Next.js поверх нативного с дополнительными опциями и кэшированием. Ключевой факт, который нужно запомнить навсегда:
// app/posts/page.tsx — ⚠️ так делать нельзя без осознанной причины:const posts = await fetch('https://api.example.com/posts').then((r) => r.json());// кэш навсегда. Акция, добавленная через админку, не появится до redeploy.Три опции управления:
// 1. Всегда свежие данные (SSR-подобно)const posts = await fetch('https://api.example.com/posts', { cache: 'no-store',});
// 2. ISR: кэш на 60 секунд, потом фоновая регенерацияconst posts = await fetch('https://api.example.com/posts', { next: { revalidate: 60 },});
// 3. Теговая ревалидация: сброс по событию, а не по таймеруconst posts = await fetch('https://api.example.com/posts', { next: { tags: ['posts'], revalidate: 3600 }, // тег + страховочный интервал});cache: 'force-cache' — явная запись дефолтного поведения (редко нужна, разве что для наглядности). Взаимоисключения: no-store несовместим с revalidate/tags — это два разных мира: «никогда не кэшировать» и «кэшировать с контролем свежести».
revalidatePath и revalidateTag
Заголовок раздела «revalidatePath и revalidateTag»Ручная ревалидация из серверного кода — Server Actions, route handlers (см. API-референс revalidateTag):
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function publishPost(id: string) { await db.post.update({ where: { id }, data: { published: true } });
revalidateTag('posts'); // сбросить все fetch с тегом 'posts' revalidatePath('/blog'); // сбросить Full Route Cache пути /blog revalidatePath(`/blog/${id}`); // и конкретной страницы поста}Разница принципиальная:
revalidatePath— точечный: сбрасывает кэш конкретного URL (и вложенных приlayout-типе). Работает на уровне маршрутов и данных, привязанных к ним.revalidateTag— концептуальный: сбрасывает все кэшированные ответы, помеченные тегом, где бы они ни использовались. Еслиpostsгрузятся и на главной, и в каталоге, и в виджете — один вызов обновляет везде.
Паттерн из практики: теги для сущностей (posts, products, user:123), revalidatePath — для URL, чьё содержимое не выводится из одной сущности (например, сложная страница с десятью источниками).
Кэширование не-fetch данных: unstable_cache
Заголовок раздела «Кэширование не-fetch данных: unstable_cache»fetch кэшируется сам, а если данные грузятся напрямую из БД через Prisma — их нужно кэшировать вручную через unstable_cache:
import { unstable_cache } from 'next/cache';import { db } from './db';
export const getFeaturedProducts = unstable_cache( async () => { // выполнится один раз, потом — из кэша return db.product.findMany({ where: { featured: true }, include: { category: true }, }); }, ['featured-products'], // ключ кэша { tags: ['products'], revalidate: 600 }, // тег + интервал);Под капотом unstable_cache кладёт результат в тот же Data Cache, что и fetch — значит, работают те же revalidateTag, та же дедупликация, тот же контроль. Почему unstable_? API ещё может поменяться между минорными версиями, но на практике используется повсеместно — альтернативы нет.
Плохой паттерн, который unstable_cache лечит:
// ❌ запрос в БД на каждый рендер страницы — дорого и медленноexport async function getCategories() { return db.category.findMany();}Пять уровней кэша: подробная таблица
Заголовок раздела «Пять уровней кэша: подробная таблица»| # | Кэш | Где живёт | Что хранит | Как сбрасывается | Время жизни |
|---|---|---|---|---|---|
| 1 | Request Memoization | Сервер, в рамках одного запроса | Результаты fetch/функций между компонентами |
Автоматически в конце запроса | Одна серверная обработка запроса |
| 2 | Data Cache | Сервер (персистентный) | Ответы fetch (force-cache/revalidate/tags), unstable_cache |
revalidateTag, revalidatePath, истечение revalidate |
От секунд до бессрочно |
| 3 | Full Route Cache | Сервер/CDN | Готовый HTML и RSC payload статических/ISR маршрутов | revalidatePath, revalidateTag (опосредованно), редeploy |
До ревалидации |
| 4 | Router Cache | Браузер | RSC payload для soft-навигации | router.refresh(), конец сессии |
5 мин (статика) / 30 сек (динамика) |
| 5 | Client Cache (fetch/browser) | Браузер | Стандартное поведение HTTP-кэша браузера | HTTP-заголовки (Cache-Control) |
По заголовкам |
Пройдём по сценариям:
Request Memoization (уровень 1). Ты вызвал fetch('/api/user') в layout, в page и в sidebar внутри одного запроса — реально выполнится один раз, два остальных возьмут результат из мемоизации. Работает только в рамках одного серверного рендера: для SSR-запроса или одной статической сборки. Это не кэш в привычном смысле — временное дедуплирование.
Data Cache (уровень 2) — главный. Персистентный между запросами. Два fetch с одинаковым URL и опциями на разных страницах — один сетевой вызов на всё приложение, пока живёт кэш.
Full Route Cache (уровень 3) зависит от уровня 2: если данные внутри страницы свежие, HTML из Full Route Cache валиден. Ревалидация данных обычно триггерит пересборку маршрута, потому что Next.js инвалидирует связанные записи.
Router Cache (уровень 4) — уже известный тебе по главе про стратегии браузерный слой: здесь «старые данные» — это норма дизайна, а не баг.
Дедупликация запросов
Заголовок раздела «Дедупликация запросов»Три механизма дедупликации работают одновременно и на разных уровнях:
- В рамках рендера (Request Memoization): повторные одинаковые вызовы схлопываются автоматически. Даже
unstable_cacheс одним ключом в пределах одного рендера — один вызов. - Между запросами (Data Cache):
fetchс одинаковыми URL+опциями переиспользует закэшированный ответ. Ключ — URL + порядок опцийnext. - На клиенте: стандартные средства — SWR/React Query, браузерный HTTP-кэш. Next.js тут не помогает — сам думай про
staleTimeи инвалидацию.
Практическое следствие: не бойся вызывать getCurrentUser() в пяти компонентах — схлопнется. Бойся вызывать fetch с no-store в пяти компонентах — это пять реальных запросов на каждый рендер страницы.
Схема выбора опций: шпаргалка для проектирования
Заголовок раздела «Схема выбора опций: шпаргалка для проектирования»Все решения по кэшированию сводятся к одному вопросу: какая задержка между изменением данных и их появлением на сайте допустима по бизнесу?
| Допустимая задержка | Опции fetch / кэша | Типовой пример |
|---|---|---|
| Только после redeploy | дефолт (force-cache) |
Версии API, редко меняющиеся справочники |
| Минуты | next: { revalidate: 60–600 } |
Каталог, курсы валют, агрегаторы |
| Секунды + событийный сброс | next: { revalidate: N, tags: [...] } |
Новости с ручной публикацией |
| Мгновенно, по событию | tags: [...] + revalidateTag из экшена |
Цена после изменения, остатки склада |
| Мгновенно, всегда свежий рендер | cache: 'no-store' |
Личный кабинет, корзина, дашборды |
Последний ряд — самый дорогой: каждый запрос идёт во все зависимости синхронно. Если видишь no-store на десяти маршрутах из двадцати — это повод пересмотреть требования, а не норма.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»-
Забытые опции fetch. Дефолтный
fetch(url)в серверном компоненте кэшируется навсегда. Плохо:fetch(url)без опций и удивление «данные не обновляются». Хорошо: явныеno-storeилиrevalidate/tagsпо требованию бизнеса. -
Ревалидация данных без ревалидации маршрута. Вызвал
revalidateTag('products'), а Full Route Cache всё ещё отдаёт старый HTML, собранный со старыми данными. В большинстве случаев Next.js сам инвалидирует связанные маршруты, но в сложных конфигурациях (ручные кэши, сложные unstable_cache) надо звать иrevalidatePath. -
Дедупликация там, где её нет. «Я вызываю fetch в layout и page — точно ли один запрос?» Да, но только если опции идентичны. Добавил в одном месте
next: { tags: [...] }, а в другом нет — ключи разные, дедупликации нет, два запроса к API. -
no-storeкак культ. Ставитьno-storeна всё «чтобы не мучиться» — это добровольный отказ от 90% ценности фреймворка: каждая страница ходит во все сервисы синхронно, TTFB растёт, база страдает. Сначала реши, какая задержка допустима — чаще всего ответ «минута-две», а не «ноль». -
Теги-односложности. Один тег
dataна всё приложение — любая ревалидация сбрасывает половину кэшей и нагружает систему пересборкой. Гранулярность тегов = гранулярность сущностей:post:42,products,user:7:orders. -
Кэширование приватных данных без разделения ключей.
unstable_cacheс ключом['user']для данных конкретного пользователя — сосед получит чужое. В ключ обязательно входит идентификатор пользователя:['user', userId, 'profile'].
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Сколько уровней кэша в Next.js и назови их. Пять: Request Memoization, Data Cache, Full Route Cache, Router Cache, клиентский HTTP-кэш браузера. Первые три — серверные, четвёртый — в браузере, пятый — стандартный HTTP.
- Что кэшируется по умолчанию при fetch в RSC? Ответ кэшируется в Data Cache бессрочно (
force-cache). Управляется опциямиcacheиnext: { revalidate, tags }. - Чем revalidatePath отличается от revalidateTag? Path — сброс по URL (точечно, включая связанные данные), Tag — сброс по концептуальной метке на всех закэшированных ответах с этим тегом, где бы они ни использовались.
- Как закэшировать результат прямого запроса в БД (Prisma)?
unstable_cache(fn, keys, { tags, revalidate })— результат попадает в тот же Data Cache с поддержкой теговой ревалидации. - Пользователь видит старые данные после мутации. Как локализуешь проблему? Проверяю по цепочке: Router Cache (браузер,
router.refresh()), Full Route Cache (путь,revalidatePath), Data Cache (теги/время,revalidateTag), затем Request Memoization (не должно переживать запрос). - Работает ли дедупликация fetch с разными опциями? Нет: ключ дедупликации включает URL и опции. Два вызова с разными
tags/revalidate— два независимых кэша. - Можно ли закэшировать персонализированные данные? Можно, но ключ обязан включать идентификатор пользователя (и нельзя отдавать такой кэш через общий Full Route Cache — маршрут должен быть динамическим).
Практика
Заголовок раздела «Практика»- В pet-проекте замени все «голые»
fetchна явные с опциями: каталог —revalidate: 300+ тегproducts, страница поста — тегpost:<slug>, личный кабинет —no-store. - Оберни топ-3 запроса Prisma в
unstable_cacheс гранулярными тегами. Из Server Action создания товара вызовиrevalidateTag('products')и убедись, что каталог обновляется без redeploy. - Смоделируй конфликт: два
fetchодного URL, в одном естьtags, в другом нет. Проверь в логах API, что запросов два. Унифицируй опции и проверь снова — должен остаться один. - Напиши страницу
/debug-cache: выводит timestamps из Data Cache (черезunstable_cache) иDate.now()рендера. Посмотри, как они расходятся при ISR и что происходит послеrevalidateTag.
Критерий результата: по любому fetch в проекте видно, каким кэшем он управляется и каким тегом сбрасывается; страница каталога обновляется событием, а не redeploy.