Продолжаю серию. В этот раз — про
С обычным
Без
У
Современные процессоры не ждут, пока проверится условие
Допустим, в памяти процесса (рядом с нашим JS-кодом) лежит секретный байт — например, кусок данных от другой вкладки. Напрямую прочитать его нельзя, есть проверка границ. Но атакующий может обойти её через спекуляцию.
Шаг 1: атакующий много раз вызывает код с валидным индексом. Предсказатель ветвлений запоминает: «условие всегда true».
Шаг 2: атакующий подаёт индекс, который выходит за границу массива и указывает на секретный байт. Процессор по привычке предсказывает «true» и спекулятивно читает секрет — допустим, значение
Шаг 3: спекулятивный код использует прочитанное значение как индекс в другом массиве —
Шаг 4: атакующий перебирает все 256 возможных значений, обращаясь к
Секрет утёк не напрямую, а через побочный канал: значение было использован как индекс в массиве и он оставил физический след в кеше.
Вся атака стоит на способности различить «3 наносекунды» и «100 наносекунд». Для этого нужен очень точный таймер.
Один воркер крутит счётчик в бесконечном цикле, другой поток читает его до и после интересующей операции. Разница — время выполнения с точностью вплоть до наносекунд. Этого достаточно, чтобы отличить попадание в кеш от промаха, а значит — достаточно для Spectre.
SharedArrayBuffer и историю с отключением во всех браузерах.SharedArrayBuffer — почти то же самое, что ArrayBuffer, но с одним принципиальным отличием: его можно передать в несколько Web Workers одновременно, и все они будут работать с одной и той же памятью.
const sab = new SharedArrayBuffer(256);
worker.postMessage(sab); // worker получает доступ к той же памяти
С обычным
ArrayBuffer при передаче через postMessage происходит transfer — оригинал становится недоступным, а получатель получает владение. Это сделано намеренно: два потока не должны одновременно писать в одну память без синхронизации. SharedArrayBuffer снимает это ограничение — но тогда синхронизацию нужно делать самостоятельно. Для этого существует объект Atomics. Он предоставляет атомарные операции — гарантированно неделимые чтение-запись:
const sab = new SharedArrayBuffer(4);
const view = new Int32Array(sab);
// В воркере 1:
Atomics.add(view, 0, 1); // атомарный инкремент
// В воркере 2:
Atomics.load(view, 0); // атомарное чтение
Без
Atomics параллельная запись в SharedArrayBuffer приводит к data race — результат непредсказуем. Atomics.add, Atomics.load и другие методы гарантируют, что операция выполнится целиком, без прерывания другим потоком. Есть и механизм ожидания: Atomics.wait(view, index, expectedValue) блокирует поток, пока значение по индексу равно ожидаемому. Atomics.notify(view, index) будит ждущие потоки.У
SharedArrayBuffer непростая история. В начале 2018 года его отключили во всех браузерах — Chrome, Firefox, Safari, Edge — одновременно. Причина: атака Spectre. Чтобы понять, при чём тут SharedArrayBuffer, нужно разобрать саму атаку — она на самом деле довольно изящная.Современные процессоры не ждут, пока проверится условие
if — они предсказывают результат и выполняют код «наперёд». Если предсказание верное — результат уже готов. Если нет — процессор откатывает результат. Но есть нюанс: данные, которые попали в кеш процессора во время спекуляции, остаются там даже после отката.Допустим, в памяти процесса (рядом с нашим JS-кодом) лежит секретный байт — например, кусок данных от другой вкладки. Напрямую прочитать его нельзя, есть проверка границ. Но атакующий может обойти её через спекуляцию.
Шаг 1: атакующий много раз вызывает код с валидным индексом. Предсказатель ветвлений запоминает: «условие всегда true».
Шаг 2: атакующий подаёт индекс, который выходит за границу массива и указывает на секретный байт. Процессор по привычке предсказывает «true» и спекулятивно читает секрет — допустим, значение
42.Шаг 3: спекулятивный код использует прочитанное значение как индекс в другом массиве —
probeArray[42 * 256]. Эта ячейка попадает в кеш. Потом процессор понимает, что условие было false, и откатывает всё. Но кеш не откатывается.Шаг 4: атакующий перебирает все 256 возможных значений, обращаясь к
probeArray[0 * 256], probeArray[1 * 256], …, probeArray[255 * 256], и замеряет время каждого обращения. Одна ячейка отвечает за ~3 наносекунды (из кеша), все остальные — за ~100 наносекунд (из оперативной памяти). Быстрая ячейка выдаёт значение секрета: 42.Секрет утёк не напрямую, а через побочный канал: значение было использован как индекс в массиве и он оставил физический след в кеше.
Вся атака стоит на способности различить «3 наносекунды» и «100 наносекунд». Для этого нужен очень точный таймер.
performance.now() давал точность ~5 микросекунд — это всё ещё достаточно грубо. А SharedArrayBuffer позволяет собрать самодельный наносекундный таймер буквально в пять строк:
// Воркер-таймер:
const counter = new Uint32Array(sharedBuffer);
while (true) {
counter[0]++;
}
// Основной поток:
const start = counter[0];
// ... замеряем что-то ...
const elapsed = counter[0] - start;
Один воркер крутит счётчик в бесконечном цикле, другой поток читает его до и после интересующей операции. Разница — время выполнения с точностью вплоть до наносекунд. Этого достаточно, чтобы отличить попадание в кеш от промаха, а значит — достаточно для Spectre.
10🔥15❤1
Сам по себе
Зачем это нужно? Суть защиты от Spectre — изоляция процессов. Chrome начал изолировать сайты в отдельные процессы ещё в 2018 году. Но без COOP/COEP страница всё ещё может загрузить cross-origin ресурс (картинку, API-ответ) в свой процесс. А если данные другого сайта попали в ваш процесс — Spectre может их прочитать. COOP/COEP гарантируют, что в процесс попадают только ресурсы, которые явно дали на это согласие. После этого
SharedArrayBuffer не опасен. Опасна возможность построить на нём таймер, который «видит» побочные эффекты спекулятивного выполнения. Именно поэтому браузеры отреагировали двумя мерами: снизили точность performance.now() (например, в Chrome — с 5 до 100 микросекунд) и полностью отключили SharedArrayBuffer.SharedArrayBuffer вернули, но только для страниц, которые изолированы от остальных. Для этого сервер должен отдавать два HTTP-заголовка:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
COOP: same-origin означает, что страница отказывается от возможности иметь ссылки на окна другого origin (попапы, window.opener). COEP: require-corp означает, что все ресурсы (картинки, скрипты, iframe), загружаемые страницей, должны явно разрешить использование через Cross-Origin-Resource-Policy.Зачем это нужно? Суть защиты от Spectre — изоляция процессов. Chrome начал изолировать сайты в отдельные процессы ещё в 2018 году. Но без COOP/COEP страница всё ещё может загрузить cross-origin ресурс (картинку, API-ответ) в свой процесс. А если данные другого сайта попали в ваш процесс — Spectre может их прочитать. COOP/COEP гарантируют, что в процесс попадают только ресурсы, которые явно дали на это согласие. После этого
SharedArrayBuffer можно безопасно включить — его таймер может читать только данные из того же (изолированного) процесса. Проверить, изолирована ли страница, можно с помощью crossOriginIsolated.👍13❤4😱3👌1
Последняя часть серии — про
Где Blob хранит данные? Из документации Chromium по blob storage system: данные
Есть обратная сторона: пока
Браузер внутри себя создаёт маппинг
Blob.Blob (Binary Large Object) — это иммутабельный объект с сырыми данными и MIME-типом. В отличие от ArrayBuffer, он не даёт прямого доступа к байтам — только асинхронно, через конвертацию:
const blob = new Blob(['<h1>Hi</h1>'], { type: 'text/html' });
const text = await blob.text();
const buf = await blob.arrayBuffer();
File, который возвращает <input type="file">, наследуется от Blob.Где Blob хранит данные? Из документации Chromium по blob storage system: данные
Blob создаются в процессе рендерера, где JS-код исполняется. Затем они передаются в процесс браузера. Подробнее про процессы можно почитать в моей статье. Если в памяти достаточно квоты — данные хранятся в RAM. Если квота заканчивается или Blob слишком большой, то браузер переносит данные на диск. Вот почему чтение из Blob асинхронное — браузер может возвращать данные с диска. Это также объясняет, почему он иммутабельный: если данные на диске, мутабельность потребовала бы файловой синхронизации, что сильно усложнило бы реализацию.blob.slice(start, end) не копирует данные, а создаёт новый Blob, который является view на часть существующего. Под капотом хранится ссылка на оригинальный Blob плюс смещение и длина.
const huge = new Blob([bigData]); // 100 МБ
const chunk = huge.slice(0, 1024); // view на первые 1 КБ, без копирования
Есть обратная сторона: пока
chunk жив, huge (и все его 100 МБ) не могут быть освобождены. Похожая ситуация с памятью может возникнуть и при создании URL на Blob.
const url = URL.createObjectURL(blob);
// url → "blob:https://example.com/550e8400-e29b-..."
img.src = url;
Браузер внутри себя создаёт маппинг
URL → Blob и удерживает Blob в памяти. Этот маппинг живёт, пока вы не вызовете URL.revokeObjectURL(url) или пока не закроется документ.👍15❤2💅2👌1🕊1
Тема того, как именно V8 понимает, что код стал «достаточно горячим» для оптимизации, зацепила меня почти сразу, как я начала копаться во внутренностях движка.
На докладах на это обычно не хватает времени — слишком много нюансов и хочется рассказать много всего и сразу. Поэтому я вынесла эту тему в отдельную статью, где подробно разобрала, как и когда происходят те самые заветные переходы между компиляторами внутри V8.
Горячий код в V8: что это значит?
На докладах на это обычно не хватает времени — слишком много нюансов и хочется рассказать много всего и сразу. Поэтому я вынесла эту тему в отдельную статью, где подробно разобрала, как и когда происходят те самые заветные переходы между компиляторами внутри V8.
Горячий код в V8: что это значит?
🔥21💅8❤2👍2💯1
Почему JSON.parse() может быть быстрее объектного литерала
Казалось бы,
Причин несколько. Во-первых, грамматика JSON тривиальна по сравнению с JS — у движка для неё отдельный, более простой и быстрый парсер. Объектный литерал — это полноценный JS-код, который проходит весь путь: токенизация → AST → байткод. Так же, как описано в блоге V8, большие объектные литералы могут парситься дважды — сначала при preparsing, потом при lazy-parsing. Строка внутри JSON.parse этой проблемы лишена.
В реальном кейсе с SSR-приложением на Redux такая замена дала улучшение Lighthouse-скора с 87 до 95 и снижение TTI на 0.7 секунды. Оптимизация актуальна и в 2026 году — фундаментальные причины никуда не делись, а V8 продолжает активно инвестировать в производительность JSON (например, недавний двукратный прирост JSON.stringify в V8 v13.8).
Казалось бы,
const config = {a: 1, b: 2, c: 3} — это самый прямой способ создать объект. Но если объект достаточно большой (от ~10 KB), JSON.parse('{"a":1,"b":2,"c":3}') окажется быстрее. На бенчмарке от GoogleChromeLabs на файле в 7 МБ JSON.parse оказался в 1.7× быстрее объектного литерала в V8, в Safari разница доходила до 2×.Причин несколько. Во-первых, грамматика JSON тривиальна по сравнению с JS — у движка для неё отдельный, более простой и быстрый парсер. Объектный литерал — это полноценный JS-код, который проходит весь путь: токенизация → AST → байткод. Так же, как описано в блоге V8, большие объектные литералы могут парситься дважды — сначала при preparsing, потом при lazy-parsing. Строка внутри JSON.parse этой проблемы лишена.
В реальном кейсе с SSR-приложением на Redux такая замена дала улучшение Lighthouse-скора с 87 до 95 и снижение TTI на 0.7 секунды. Оптимизация актуальна и в 2026 году — фундаментальные причины никуда не делись, а V8 продолжает активно инвестировать в производительность JSON (например, недавний двукратный прирост JSON.stringify в V8 v13.8).
🤯30🔥9❤2👍2💅1
Что такое back/forward cache (bfcache)
Когда пользователь нажимает «назад» или «вперёд», браузер может не загружать страницу заново, а достать из памяти полный снимок: DOM, JS-кучу, состояние скролла — всё. Страница замораживается при уходе и размораживается при возврате. Переход ощущается мгновенно, потому что это не навигация в привычном смысле — это restore.
Браузер делает эту оптимизацию автоматически, но страница должна соответствовать определённым условиям. Она не попадёт в bfcache, если:
- есть listener на
- открыт WebSocket
- на документе стоит
- есть незавершённая `IndexedDB`транзакция
- и другие причины
Полный список можно посмотреть на MDN.
Диагностировать всё это можно прямо в DevTools: вкладка Application → Back/forward cache. Там можно протестировать, попадает ли страница в bfcache, и если нет — увидеть конкретный список блокеров.
Для продакшена есть программный способ — PerformanceNavigationTiming.notRestoredReasons. Через него можно собирать данные об использовании bfcache в RUM-метриках и понимать, что ломает кэш у реальных пользователей.
По итогу bfcache — один из самых «дешёвых» способов ускорить воспринимаемую производительность. Ничего не нужно дополнительно оптимизировать — достаточно не ломать нативное поведение браузера.
Когда пользователь нажимает «назад» или «вперёд», браузер может не загружать страницу заново, а достать из памяти полный снимок: DOM, JS-кучу, состояние скролла — всё. Страница замораживается при уходе и размораживается при возврате. Переход ощущается мгновенно, потому что это не навигация в привычном смысле — это restore.
Браузер делает эту оптимизацию автоматически, но страница должна соответствовать определённым условиям. Она не попадёт в bfcache, если:
- есть listener на
unload- открыт WebSocket
- на документе стоит
Cache-Control: no-store- есть незавершённая `IndexedDB`транзакция
- и другие причины
Полный список можно посмотреть на MDN.
Диагностировать всё это можно прямо в DevTools: вкладка Application → Back/forward cache. Там можно протестировать, попадает ли страница в bfcache, и если нет — увидеть конкретный список блокеров.
Для продакшена есть программный способ — PerformanceNavigationTiming.notRestoredReasons. Через него можно собирать данные об использовании bfcache в RUM-метриках и понимать, что ломает кэш у реальных пользователей.
По итогу bfcache — один из самых «дешёвых» способов ускорить воспринимаемую производительность. Ничего не нужно дополнительно оптимизировать — достаточно не ломать нативное поведение браузера.
🔥35👍4❤3👏1💅1
Продолжая тему с небольшими оптимизациями в браузере — сегодня поговорим про заголовок
Полностью он используется так:
Что здесь происходит:
- 0–600 сек — ресурс свежий, отдаётся из кэша
- 600–630 сек — ресурс устарел, но браузер всё равно отдаёт его мгновенно, а в фоне идёт за новой версией
- после 630 сек — кэш полностью стух, пользователь ждёт
Ключевой момент: пользователь, попавший в stale-окно, видит старую версию. Фоновый запрос незаметно обновляет кэш, и уже в следующий раз посетитель получит свежие данные.
Где его используем, а где нет?
- Нехешированная статика (`/logo.png`, `/fonts/custom.woff2`) — используем, потому что URL не меняется при обновлении файла.
- API-ответы и HTML, которые меняются нечасто (каталог, лендинг, результаты поиска) — тоже используем, это даёт мгновенный ответ, а данные отстают максимум на пару минут.
- Хешированная статика (`main.a3f8c2.js`) — не имеет смысла использовать
- Критичные данные (баланс, цена, статус заказа) — тут уже нужен
И да, паттерн SWR знаком многим по React-библиотекам (swr, React Query), но лично я раньше не задумывалась о том, что это буквально тот же принцип, только взятый из HTTP.
stale-while-revalidate.Полностью он используется так:
Cache-Control: max-age=600, stale-while-revalidate=30Что здесь происходит:
- 0–600 сек — ресурс свежий, отдаётся из кэша
- 600–630 сек — ресурс устарел, но браузер всё равно отдаёт его мгновенно, а в фоне идёт за новой версией
- после 630 сек — кэш полностью стух, пользователь ждёт
Ключевой момент: пользователь, попавший в stale-окно, видит старую версию. Фоновый запрос незаметно обновляет кэш, и уже в следующий раз посетитель получит свежие данные.
Где его используем, а где нет?
- Нехешированная статика (`/logo.png`, `/fonts/custom.woff2`) — используем, потому что URL не меняется при обновлении файла.
- API-ответы и HTML, которые меняются нечасто (каталог, лендинг, результаты поиска) — тоже используем, это даёт мгновенный ответ, а данные отстают максимум на пару минут.
- Хешированная статика (`main.a3f8c2.js`) — не имеет смысла использовать
stale-while-revalidate, тут правильнее будет immutable, потому что файл по этому URL с хэшом не изменится никогда.- Критичные данные (баланс, цена, статус заказа) — тут уже нужен
no-cache.И да, паттерн SWR знаком многим по React-библиотекам (swr, React Query), но лично я раньше не задумывалась о том, что это буквально тот же принцип, только взятый из HTTP.
❤20👀1💅1
Недавно на рабочем проекте я обновляла Next.js с 12-й на 16-ю версию. Далось непросто, так как были кастомный сервер на NestJS и связка через пакет nest-next, который последний раз обновлялся три года назад. Больно и неприкольно, но мы справились 💪
Этот опыт вдохновил меня наконец заняться тем, что я так долго откладывала — сходить в отпуск. Ну и написать цикл статей про Next.js)
Так что впереди нас ждёт трёхнедельный перерыв на канале, а после него — новый цикл, не переключайтесь!
Этот опыт вдохновил меня наконец заняться тем, что я так долго откладывала — сходить в отпуск. Ну и написать цикл статей про Next.js)
Так что впереди нас ждёт трёхнедельный перерыв на канале, а после него — новый цикл, не переключайтесь!
❤49🔥16👍6🙏1💯1💅1
А вот и обещанный новый цикл про Next.js под капотом.
И в первой части разберём, из каких слоёв состоит Next.js как система, как они связаны друг с другом и почему архитектура стала именно такой.
Next.js изнутри. Часть 1. Архитектура Next.js.
И в первой части разберём, из каких слоёв состоит Next.js как система, как они связаны друг с другом и почему архитектура стала именно такой.
Next.js изнутри. Часть 1. Архитектура Next.js.
❤23🔥4💯1💅1