Настя Котова // Frontend & Node.js
1.23K subscribers
49 photos
3 files
135 links
Фронтендерица с лапками 🐾
Посты каждый понедельник 💃 Копаюсь во внутрянке технологий и рассказываю вам
Download Telegram
Продолжаю про работу с сырыми данными. В этот раз — про Buffer.

Buffer — это Node.js-специфичная вещь, в браузере его нет. Технически он наследуется от Uint8Array, то есть это тот же TypedArray, но с дополнительными методами для удобной работы со строками и файлами:


const b = Buffer.from('hello', 'utf8');
b.toString('hex'); // '68656c6c6f'


У Buffer есть три статических метода для аллокации, и они работают принципиально по-разному.

Buffer.alloc(size) — безопасный вариант. Создаёт отдельный ArrayBuffer нужного размера, память заполняется нулями. Пул не используется.


Buffer.alloc(8); // <Buffer 00 00 00 00 00 00 00 00>


Buffer.allocUnsafe(size) — быстрый вариант. Память не обнуляется, в буфере могут быть остатки старых данных. Но самое интересное — это то, как он выделяет память.

Buffer.allocUnsafeSlow(size) — как allocUnsafe, но всегда создаёт отдельный ArrayBuffer, без пула. Зачем — объясню ниже.

Когда вы вызываете Buffer.allocUnsafe(100), Node.js не идёт каждый раз просить систему выделить 100 байт. Вместо этого он заранее выделяет один большой кусок памяти на 8 КБ (пул) и нарезает от него маленькие буферы:

Если запрошенный размер меньше 4096 байт (половина от размера всего пула), Node.js просто двигает указатель внутри пула и создаёт новый view на уже существующий ArrayBuffer. Новой аллокации памяти нет, поэтому это очень быстро.

Если в текущем пуле не хватает места — создаётся новый. Если запрошенный буфер слишком большой (≥ 4096) — пул не используется, создаётся отдельный ArrayBuffer.

Порог в половину пула выбран не случайно: если буфер занимает больше половины, он съест почти весь пул, а оставшийся хвост станет бесполезным.

После каждой нарезки poolOffset выравнивается до границы в 8 байт. Это нужно для производительности: невыровненный доступ к памяти на некоторых архитектурах медленнее.

Все маленькие буферы, нарезанные из одного пула, разделяют один ArrayBuffer. Это значит: пока хотя бы один такой буфер жив, все 8 КБ пула не могут быть собраны GC.

Именно для этого существует Buffer.allocUnsafeSlow(size) — он всегда создаёт отдельный ArrayBuffer. Если вы создаёте много маленьких буферов и удерживаете их надолго (кешируете, складываете в массив), allocUnsafeSlow может быть экономичнее по памяти.
9👍52🔥1
Продолжаю серию. В этот раз — про 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🔥151
Сам по себе 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.
👍134😱3👌1
Последняя часть серии — про 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) или пока не закроется документ.
👍152💅2👌1🕊1
Тема того, как именно V8 понимает, что код стал «достаточно горячим» для оптимизации, зацепила меня почти сразу, как я начала копаться во внутренностях движка.

На докладах на это обычно не хватает времени — слишком много нюансов и хочется рассказать много всего и сразу. Поэтому я вынесла эту тему в отдельную статью, где подробно разобрала, как и когда происходят те самые заветные переходы между компиляторами внутри V8.

Горячий код в V8: что это значит?
🔥21💅82👍2💯1
Почему JSON.parse() может быть быстрее объектного литерала

Казалось бы, 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🔥92👍2💅1
Что такое back/forward cache (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👍43👏1💅1
Продолжая тему с небольшими оптимизациями в браузере — сегодня поговорим про заголовок 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)

Так что впереди нас ждёт трёхнедельный перерыв на канале, а после него — новый цикл, не переключайтесь!
49🔥16👍6🙏1💯1💅1
А вот и обещанный новый цикл про Next.js под капотом.

И в первой части разберём, из каких слоёв состоит Next.js как система, как они связаны друг с другом и почему архитектура стала именно такой.

Next.js изнутри. Часть 1. Архитектура Next.js.
23🔥4💯1💅1