Поговорим про микро-оптимизацию. Про, казалось бы, самую безобидную вещь: обычный {}.

[object Object] выглядит почти бесплатной абстракцией. Создал объект, напихал полей в любом порядке, удалил ненужное, передал дальше и забыл. Но, внезапно, одинаковые для тебя объекты могут оказаться разными для V8, порядок присваиваний влияет на машинный код, а “безобидный” delete оставляет после себя совсем не пустое место.

Для 99% систем это не имеет значения. А вот если у тебя hot path, через который проходят десятки тысяч объектов в секунду, или ты пишешь библиотечный код, стоит знать, что V8 на самом деле делает с твоими объектами.

И чего он там с ними делает?

Когда ты создаёшь объект, V8 не выделяет «мешок ключей». Он строит для него hidden class (map внутри V8) структуру, которая описывает layout: какие у объекта поля, в каком порядке они лежат, какого типа, по каким офсетам читать. Сам объект это компактный массив значений, как struct в C. Hidden class хранится отдельно, и каждый объект держит на него ссылку.

Hidden classes связаны между собой через transitions и вместе образуют transition tree. Когда ты добавляешь поле к объекту, V8 идёт по исходящему переходу из текущего hidden class: если переход с таким полем уже есть переключается на существующий дочерний, если нет создаёт новый hidden class и новую ветку дерева.

Если несколько объектов идут по одним и тем же переходам в одинаковом порядке они разделяют один hidden class. Это даёт V8 базу для inline cache: «по этому офсету всегда лежит string, читаем напрямую, без проверок».

Когда hidden classes начинают расходиться (по порядку, по типам, по добавлению/удалению), inline cache переходит:

  • monomorphic (один shape — fast path),

  • polymorphic (до 4 shapes — V8 проверяет каждый, всё ещё быстро),

  • megamorphic (4+ — V8 сдаётся, идёт через generic lookup).

Стоимость растёт на каждом шаге.

Где ты теряешь?

Классический пример постепенная сборка vs литерал:

const a = {}
a.id = 1
a.name = 'jopa'

const b = { id: 1, name: 'jopa' }

Если порядок добавления полей совпадает, оба варианта в итоге попадают на один hidden class. У литерала есть бонус V8 запоминает финальный shape как boilerplate и при повторных вызовах аллоцирует объект сразу с готовым layout, без прохода по transition tree. Но в hot loop разница в установившемся режиме копеечная.

Хуже, если порядок полей в разных фабриках не совпадает:

function makeA(id, name) { return { id, name } }
function makeB(name, id) { return { name, id } }

Это уже два разных hidden class. Любой код, который читает obj.id через эти фабрики, видит два shape на одном call site и IC становится polymorphic. Само по себе ещё не больно, но если таких разных объектов не два, а шесть, то добро пожаловать в megamorphic.

Ещё кейс условное добавление полей:

const user = { id, name }

if (isAdmin) {
  user.role = 'admin'
}

Часть объектов имеет shape {id, name}, часть — {id, name, role}. Уже polymorphic. Лучше класть role: null сразу и потом присвоить значение при необходимости.

delete — отдельная история

delete user.name

Вот тут V8 уже не прощает. delete переводит объект в dictionary mode (он же slow mode, normalized properties). Это hashmap вместо фиксированного layout. Ни inline cache, ни офсетов, каждое чтение поля идёт через hash lookup в C++ runtime. И обратно V8 объект уже не вернёт, даже если форма стабилизировалась.

Вроде как память освободил. А по факту превратил объект в Map без типизации.

Если поле больше не нужно лучше присвой null или undefined. Shape сохранится, движок не потеряет в оптимизации.

А что по цифрам?

Стенд: Node 24, миллион объектов с тремя полями, в hot loop читаем obj.id, меряем ns/op. По три прогона, чтобы JIT успел устаканиться.

  • literal (same shape): 3.99 нс

  • stepwise (same order): 4.08 нс

  • poly IC (2 shape): 4.65 нс

  • megamorphic (6 shapes): 8.51 нс

  • dict mode (after delete): 56.12 нс

Что видно:

  • literal vs stepwise — разницы практически нет. V8 одинаково хорошо справляется с обоими.

  • polymorphic IC (2 shape) — ~15% оверхед. V8 спокойно тянет до 4 shape на одном call site.

  • megamorphic (6 shape)x2+ к чтению. Уже серьёзно.

  • delete (dictionary mode)x14 медленнее. Больно :)

На 100k RPS × 50 чтений полей на запрос с dict-mode объектами вместо нормальных — потеря ~260 мс CPU/с.

А что было на Node 22?

Для наглядности — те же сценарии на Node 22:

  • literal: 11.0 → 4.0 нс (x2.8)

  • stepwise: 11.1 → 4.1 нс (x2.7)

  • poly (2 shape): 10.3 → 4.7 нс (x2.2)

  • megamorphic: 14.1 → 8.5 нс (x1.7)

  • dict mode: 56.8 → 56.1 нс (=)

Что интересно:

  • Fast path между релизами стал ~x2.5 быстрее. TurboFan и Maglev продолжают тюниться, монохромное чтение поля на Node 24 — 4 нс против 11 на Node 22.

  • Dict mode — константа. 56 нс на обеих версиях. Это C++ hashmap lookup, JIT там не играет, оптимизировать нечего.

  • Относительная разница fast/slow растёт. На Node 22 delete был x5 медленнее literal’а, на Node 24 — уже x14. Fast path уходит вперёд, slow path стоит.

Вывод: с каждым новым V8 сидеть в dictionary mode или megamorphic IC становится всё дороже относительно “правильного” кода.

А что делать то?

  • В hot path — литералы, со всеми полями сразу. Не знаешь значение полож null.

  • Поля во всех фабриках — в одном порядке. Делаешь { id, name }, делай везде { id, name }.

  • delete — не нужон. Только obj.field = null (или undefined).

  • Если объект сложный и часто создаётся — класс или фабрика. Один shape гарантированно.

  • Не стоит превращать один call site в универсальную точку на все случаи жизни.: четыре shape потолок V8, дальше generic.

Что в итоге?

Объект в JS не «просто словарь». Это контракт с V8: ты обещаешь стабильный shape, V8 обещает быструю работу через hidden classes и inline cache. Нарушаешь — съезжаешь в polymorphic, потом megamorphic, потом dictionary mode. С каждым шагом дороже.

В обычном CRUD это не заметно. На hot path — это десятки процентов CPU и заметные хвосты по p99.

Date.now() тратил CPU на syscall, parseFloat — на парсинг, плохой shape — на cache miss. А потом говорят: “ой да ну какой Node, он же медленный, давайте напишем на Go и пойдем за ванильным лате на растительном”

Но shape, на самом деле, это только половина истории. Даже при том же hidden class смена типа значения в поле способна инвалидировать уже оптимизированный код. В следующей статье расскажу, что V8 на самом деле хранит за number, string и null, при чём тут Smi, Double, HeapObject и Tagged и почему совет «положи null заранее» работает не для каждого поля.

Node быстрый. Просто не мешай ему.

Что почитать?

Комментарии (25)


  1. KivApple
    05.08.2026 10:08

    Как я понимаю, если использовать class, описывать в нем явно все поля и т п, то все эти оптимизации будут из коробки?


    1. VaskaDeGama Автор
      05.08.2026 10:08

      Почти. Явно объявленные поля класса дают экземплярам один стабильный shape, и V8 сможет использовать быстрый доступ. Этом в целом и все, если поля удалять, добавлять деоптимизация придет, так же как и с обычным объектом


    1. artptr86
      05.08.2026 10:08

      По идее нет. В классе же порядок полей не специфицируется. Декларация полей есть только в TypeScript. С конструктором, кстати, интересно: будет ли последовательная инициализация this.a = ...; this.b = ...; this.c = ...; порождать отдельные скрытые классы, или в случае линейного кода V8 догадается это оптимизировать?


      1. VaskaDeGama Автор
        05.08.2026 10:08

        При this.a = ...; this.b = ...; this.c = ...; V8 проходит через промежуточные формы по дереву переходов, но переиспользует их. Поэтому все экземпляры в итоге получают одну финальную форму.

        А поля класса инициализируются в порядке объявления, поэтому форма экземпляров будет стабильной.


        1. artptr86
          05.08.2026 10:08

          Значит, главное, чтобы во всяких условных ветках внутри конструктора, последовательность инициализации была одной и той же?


          1. VaskaDeGama Автор
            05.08.2026 10:08

            Если быть точным, у всех экземпляров должны совпадать и порядок, и набор полей. Лучше создать все поля до ветвления, а внутри веток менять только их значения. Иначе разные ветки могут породить разные финальные формы.


  1. genseq
    05.08.2026 10:08

    Если отвалились почки, то поздно пить боржоми. Стоит ли сейчас копаться в особенностях кода у JS? Все проблемы с JS уже решаются при помощи ИИ. Я ничего не смыслю в программировании, но недавно написал пару программ (JS в формате HTML, 1000...1500 строк кода). Работают в любом браузере и быстро обрабатывают многомиллионные массивы данных, представляя их в графическом формате (Plotly.js). Использовал несколько бесплатных ИИ. Иногда неплохо справлялись китайские. GigaChat для подобных задач явно ещё не созрел. Очень неплохо проявил себя Gemini. ChatGpt долго пудрил мозги, но код писать не стал. А вот к Claude (Sonnet 5) претензий нет. Он блестяще справился со всеми задачами. Правда, из-за лимитов бесплатного доступа пришлось обращаться к нему несколько раз. Хорошо, что мне торопиться некуда.


    1. VaskaDeGama Автор
      05.08.2026 10:08

      Ваш пример как раз показывает, что ИИ сильно снизил порог входа, и это прекрасно. Но он помог решить конкретную прикладную задачу. Это ещё не значит, что знание языка и рантайма стало ненужным. Вы сформулировали требования, сравнили модели, провели несколько итераций и проверили результат. Клод написал код, но постановщиком, тестировщиком и экспертом в предметной области остались вы.

      Копаться в устройстве V8 действительно нужно не каждому. Я не вижу ничего плохого в использовании ИИ-болванов от разных производителей при решении задач. Наоборот, только за: они здорово помогают в работе и исследованиях. Только замечу, что оптимальный код они пишут далеко не всегда.

      Когда от кода требуется укладываться в бюджет CPU и памяти и держать SLA с нужным количеством девяток, без экспертизы будет сложновато даже с фронтирными моделями. Если не знать, куда направить модель, сама она туда может и не пойти.


    1. JerryI
      05.08.2026 10:08

      Я ничего не смыслю в программировании

      Тогда на кой вы пришли в комментарии сюда


    1. wii
      05.08.2026 10:08

      Смею предположить, что если если во фразе “Все проблемы с JS уже решаются при помощи ИИ. Я ничего не смыслю в программировании”, между “JS” и “уже”, поставить недостающую часть ", что мне известны, ", то результат будет очевиднее. Иначе непонятно, какие именно “все”, кем, зачем и для чего они решаются.


  1. TheHost
    05.08.2026 10:08

    Столько букв и ни слова про Object.create(), который создаёт реально пустой объект, не наследуя мусор.

    Да и сори пост выглядит нейрослопно


    1. VaskaDeGama Автор
      05.08.2026 10:08

      Да, Object.create(null) стоило упомянуть. Он действительно создаёт объект без прототипа, но на уровне V8 это не «реально пустой» объект: он сразу создаётся в dictionary mode. В моём бенчмарке на Node 20/22/24 создание такого объекта с тремя полями примерно в 6 раз медленнее литерала {}. Поэтому от описанных в статье проблем он не спасает, а фактически сразу выбирает slow path. Для словаря без унаследованных свойств вещь полезная, но обычные объекты им заменять точно не стоит.


      1. TheHost
        05.08.2026 10:08

        Ты и для комментариев ии юзаешь?)) Не надо так)


        1. VaskaDeGama Автор
          05.08.2026 10:08

          А еще этот же ии, я на митап выступать отправил)


          1. TheHost
            05.08.2026 10:08

            Ну нет предела ереси агентной, таково время.


            Рассмотрите в качестве бенчмарка проход по ключам, аля словари, добавление ключей и так далее. Там будет процентов 10%+ у object.create. Map за рамками, но понятно еще быстрее. Мы же их для чего-то создаем, а не просто так))

            Вот тут V8 уже не прощает. delete переводит объект в dictionary mode (он же slow mode, normalized properties). Это hashmap вместо фиксированного layout. Ни inline cache, ни офсетов, каждое чтение поля идёт через hash lookup в C++ runtime. И обратно V8 объект уже не вернёт, даже если форма стабилизировалась.

            да да, вот что бы у вас хешмепа была, и подойдет object.create. Короче помощнее ИИшку найдите))

            п.c. воть, и удаление быстрее!)


            1. VaskaDeGama Автор
              05.08.2026 10:08

              А на какой версии ноды? И как замеряли?


              1. TheHost
                05.08.2026 10:08

                Есть момент аллокации пустого .create, аллоцируется объект, но его property store хеш, а не массив. Это валидно для V8, ну и может в стандарте так. Смотри просто доклады инженеров V8, а не слушай нейронку. Benedikt Meurer например пару докладов прикольных делал, с юморком


                1. VaskaDeGama Автор
                  05.08.2026 10:08

                  Мы как будто про разное. Я в статье и бенчах, про стоимость доступа к известном полю при разных формах объекта.

                  Вы же про совсем другой сценарий.

                  Подскажите, мне для моей ии, на какой версии ноды замеры на скрине? Я пока буду смотреть доклады инженеров V8, бенчмарки погоняю, померяю. Может пойму что-нибудь)

                  И в целом спасибо, что дали тему для новой статьи)


                  1. TheHost
                    05.08.2026 10:08

                    Ну вы delete упоминали? Цитата выше? Да, с очень поверхностными и на половину верными утверждениями. Про бесплатность и скобок {}, прямо в заголовке. Но ноль про namedictionary, object.create и прочее.

                    То есть вы не владеете и не понимаете тему, но пушите пост, ходите там на какие то митапы выступать. Даже этот вопрос про версию ноды. Это фундамент V8 с первой версии. Берите любую.

                    про стоимость доступа к известном полю

                    Опять же в ряде случаев .create будет лучше, особенно если доступ к несуществующему. {} c прототипом пойдет проверять цепочку.


                    1. VaskaDeGama Автор
                      05.08.2026 10:08

                      Ну зачем так сразу, бросаться "не владеете темой".

                      Я не учебник по V8 принёс. Статья про конкретный сценарий: стоимость доступа к известному полю при стабильной и нестабильной форме объекта.

                      Все ваши аргументы, интересны, правильны и достойны внимания, но сценарий другой.

                      Стандарт гарантирует только null в прототипе. А вот реализация зависит от версии движка. Поэтому вопрос про версию nodejs и V8, считаю базовым при любой разговоре про бенчмарки и производительность.

                      Даже за примером далеко не надо ходить, у меня в бенчах, V8 11.3 удаление последнего добавленного поля оставляет объект в fast properties. А вот 12.4 и 13.6 уже сваливает обьект в dictionary mode.

                      Поэтому «берите любую версию» для бенчмарка не работает.
                      Так вот какая версия в ваших замерах выше? Мне так, для моего ии.

                      И да, буду рад прочитать ваш материал про namedictionary, .create, и каким образом это влияет на скорость доступа к известному полю.


                      1. TheHost
                        05.08.2026 10:08

                        Стандарт гарантирует только null в прототипе

                        24% процента разница при 50 на 50 совпадении, за счет того, что "только null в прототипе"

                        https://jsperf.app/nofuhe


                      1. VaskaDeGama Автор
                        05.08.2026 10:08

                        Тут сложно поспорить, так как нет поиска по цепочке прототипов) Но причем тут чтение отсутствующего поля?


            1. VaskaDeGama Автор
              05.08.2026 10:08

              По моим замерам доступа к известному полю на разных версиях ноды, обьект без прототипа уступает


  1. DiRo
    05.08.2026 10:08

    Добавлю сценарий, где форма ломается без единой фабрики: объекты, которые ты не создавал сам, а получил из JSON.parse. Мы разбираем товарные выгрузки прямо в браузере — 32 772 позиции в 1387 файлах, и файлы собраны разными выгрузчиками, так что порядок ключей в них гуляет. Кода при этом один вариант, литералов нет вообще, а call site, читающий одно и то же поле, всё равно видит несколько форм: форму задал не программист, а текст входного файла. Лечится нормализацией — прогнать распарсенное через фабрику с фиксированным порядком полей, — но со стороны это выглядит как лишнее копирование миллиона объектов, и без вашей статьи объяснить, зачем оно, тяжело. А сам нормализующий проход не мерили? Интересно, с какого числа чтений на объект он начинает окупаться.


    1. VaskaDeGama Автор
      05.08.2026 10:08

      Интересный кейс. По опыту, данные на входе в систему в любом случае нужно валидировать, приводить поля и типы к единой схеме, добавлять дефолтные значения, идентифицировать и дедуплицировать. Особенно когда источников данных больше двух) Если в этом же проходе перекладывать JSON в единый внутренний DTO, стабильный порядок полей будет приятным бонусом и отдельного прохода ради V8 не понадобится)

      За это, правда, нужно будет заплатить временным ростом потребления памяти, дополнительными аллокациями и нагрузкой на GC. Но пользы от такого прохода, как мне кажется, будет больше, а стабильные формы объектов тут скорее дополнительный бонус.

      Насчёт замеров: кейс интересный, можно заморочиться и сделать бенч. Спасибо за кейс и идею.