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

Сначала про цифры
Для бенчмарка я взял круглую цифру: синтетический чат на 500 тысяч сообщений. Ещё вчера сайт такой файл не принял бы: он весит 197 МБ, а мы отказывали всем, у кого JSON больше 150 МБ. Лимит стоял из-за JSON.parse: ему нужен весь текст одной строкой, а строка в браузере не больше полугигабайта-гигабайта, на телефоне меньше.
Поводом убрать лимит стала наша же аналитика. Один человек с чатом больше 300 тысяч сообщений четыре раза подряд выбрал result.json, четыре раза получил «Файл больше 150 МБ», а через 18 минут загрузил заново, уже без медиа. Большие чаты как раз самые интересные, и именно их мы отгоняли. Теперь файл читается кусками, об этом отдельный раздел ниже. А пока цифры.
Для проверки я прогнал через движок свои переписки: 16 диалогов и бесед из Telegram, ВКонтакте и WhatsApp, 808 тысяч сообщений за 11 лет. Самая большая беседа в выгрузке ВК — 194 тысячи сообщений, самый большой личный диалог в Telegram — 92 тысячи. Плюс синтетический чат на те самые 500 тысяч. Вот сколько в браузере проходит от выбора файла до экрана с карточками:

Как мерил: настольный Ryzen 7 5800X, WebKit (движок Safari) под Playwright, медиана трёх запусков, сайт в production-сборке. В это время входят чтение файла, разбор, все метрики и два хеша чата (про них ниже). На телефонах я пока не мерил, поэтому там ожидается чуть больше.
Что тут видно:
Обычные личные чаты — это секунды. 92 тысячи сообщений, 41 МБ JSON — 2,3 секунды.
Синтетические 500 тысяч — 5,7 секунды. Этот файл на 197 МБ раньше отклонялся, теперь читается кусками.
Беседа ВК на 194 тысячи — 11 секунд. Это много, и это мой следующий кандидат на оптимизацию. В Node на разбор HTML в windows-1251 и на сами метрики уходит примерно поровну, около четырёх секунд на каждое.
Синтетика оптимистичнее жизни. Синтетика считается быстрее, чем живые чаты: те же 100 тысяч сообщений — 1,3 секунды (Node), а реальной беседе на 194 тысячи одни только метрики нужны около 4 секунд: сообщений в два раза больше, а времени втрое. Почему так, я ещё не раскладывал по шагам. Похоже, дело в более длинных текстах и в числе участников: в этой беседе их 153.
Теперь по порядку: что мы вообще разбираем и как устроен расчёт.
Что на входе
Четыре источника:
Экспорт Telegram Desktop: один JSON (или HTML) на чат. У активной пары за несколько лет это 50–150 тысяч сообщений и десятки мегабайт, а если выгрузить ещё и медиа, то сотни мегабайт и гигабайты: в JSON попадают длинные поля про каждое фото и видео. Внутри
date_unixtime, автор, текст (строкой или массивом кусочков с разметкой), реакции, стикеры, голосовые, звонки. Экспортировать можно только с компьютера: в мобильных приложениях и веб-версии экспорта нет.Архив данных ВКонтакте —
Archive.zip, где каждая переписка разложена по HTML-страницам в windows-1251. У меня 82 МБ, 35 тысяч файлов и 7 195 диалогов, а нужен обычно один.Экспорт WhatsApp — текстовый файл или zip. Тут самое интересное — даты. У iPhone строка начинается с
[28.09.2026, 10:15:03], у Android с28.09.2026, 10:15 -, а в английской локали вместо этого9/28/26, 10:15 AM. Что в строке день, а что месяц, файл не подписывает: порядок приходится определять по всему файлу. А перед «AM» iPhone ставит узкий неразрывный пробел U+202F, на котором ломаются самые простые регулярки.
На выходе — ChatReport: только числа, даты, ключи участников и одиночные слова из словарей. Из него рисуются карточки.
Одноразовый воркер
Разбор и расчёт живут в Web Worker. Воркер создаётся на один файл и убивается после ответа. Упрощённо:
export function runAnalysis(source: AnalysisSource, timeZone: string, onProgress: (p: Progress) => void): Promise<AnalysisResult> { const worker = new Worker(new URL('./analyze.worker.ts', import.meta.url), { type: 'module', name: 'analyze' }); return new Promise((resolve, reject) => { const finish = () => worker.terminate(); worker.onmessage = (event) => { const msg = event.data; if (msg.type === 'progress') onProgress(msg); else if (msg.type === 'done') { finish(); resolve(msg.result); } else if (msg.type === 'error') { finish(); reject(new AppError(msg.code, msg.message)); } }; // Например, нехватка памяти на очень большом файле. worker. => { event.preventDefault(); finish(); reject(new AppError('worker_failed', event.message)); }; worker.postMessage({ op: 'analyze', source, timeZone }); }); }
Зачем так, а не долгоживущий воркер:
Интерфейс не замирает. Разбор 40-мегабайтного JSON и три десятка метрик — секунды работы процессора. В основном потоке телефон просто завис бы.
Память освобождается целиком. Текст переписки живёт только внутри воркера. Наружу через
postMessageвыходит отчёт: для чата на 92 тысячи сообщений это 40 КБ чисел.terminate()выбрасывает всё остальное разом, и не нужно надеяться, что сборщик мусора когда-нибудь доберётся до гигантского массива строк.Граница приватности видна в коде. Всё, что пересекает
postMessage, — кандидат на утечку. Таких потоков три: прогресс, отчёт и два небольших довеска для экрана — несколько цитат «по нажатию» (5 КБ) и материал для книги (26 КБ). Их легко проверить глазами.

Текстовый слой: трогаем строки один раз
Первая версия была честной и медленной. Каждая метрика сама бегала по сообщениям и сама делала toLowerCase(), регулярки и разбиение на слова: «любимые слова», «эмодзи», «кто пишет „люблю“», «кто смеётся», «словечки пары» — каждая по-своему. На 500 тысячах сообщений это давало около четырёх секунд, и почти всё — повторная работа с одними и теми же строками.
Решение скучное: текстовый слой. Один проход по всем сообщениям, после которого строки больше никто не трогает. Метрики работают с числовыми массивами:
export interface TextLayer { /** id → словоформа в нижнем регистре с «ё» → «е». */ vocab: string[]; vocabIndex: Map<string, number>; /** Токены всех сообщений подряд: id | BOUNDARY. */ tokens: Uint32Array; /** Токены сообщения i — tokens[tokenStart[i] .. tokenStart[i + 1]). */ tokenStart: Uint32Array; /** Признаки сообщения: вопрос, улыбка скобкой, точка в конце, КАПС… */ flags: Uint32Array; /** Признаки словоформы по id: «люблю», «мы», «скучаю», слово-паразит… */ lex: Uint32Array; /** Сколько раз встретилась словоформа. */ count: Uint32Array; /** Сколько раз словоформа написана с заглавной не в начале сообщения — признак имени. */ capitalizedMid: Uint32Array; // …и ещё несколько счётчиков такого же рода }
Что это даёт:
Словарь словоформ вместо строк. Каждое слово встречается в
vocabодин раз, дальше по всему чату — только его номер вUint32Array. «Люблю» в сотый раз — это сравнение двух чисел, а не двух строк.Признаки — битами. Вопрос ли это, есть ли улыбка скобкой, стоит ли точка в конце — один
Uint32на сообщение. Метрика «кто ставит точки» превращается в цикл поflagsс маской.Словари проверяются один раз на словоформу, а не на каждое вхождение. Если «скучаю» помечено битом в
lex, метрике «кто больше скучает» не нужна ни одна регулярка.Слой ленивый и кэшируется на контексте расчёта (
WeakMap<Context, TextLayer>): его строит первая метрика, которой он понадобился.
Одна мелочь, которая сэкономила нам вечер: capitalizedMid — сколько раз слово написано с заглавной не в начале сообщения. Имена собеседников почти всегда такие, обычные слова почти никогда. Без этого поля в «ваших словечках» всплывали имена друзей.

Что это дало на синтетике в 500 тысяч сообщений (замеры от 16 сентября, Node): весь расчёт — с 4,1 до 3,3 секунды, а метрика «любимые слова» — с 881 до примерно 80 миллисекунд.
Бюджет скорости
Скорость у нас не пожелание, а бюджет с журналом замеров. В CI один тест: синтетический чат на 100 тысяч сообщений должен пройти разбор и все метрики быстрее 10 секунд. Бюджет на 500 тысяч держим вручную по таблице в репозитории: если PR трогает ядро, прогоняем бенчмарк, и если время выросло, сначала находим, какой шаг, а уже потом спорим, нужна ли метрика.
Честная оговорка: с тех пор мы добавили много новых метрик, и сегодня те же 500 тысяч синтетики считаются за 4,9 секунды (всего с разбором — 5,9 с), а 100 тысяч — за 1,1. Бюджет выдерживаем, но выигрыш от слоя «съели» новыми возможностями.
Отдельно про «ваши словечки». Это слова, которые у пары встречаются намного чаще, чем в языке вообще. Для этого нужна частотность русских словоформ — статический файл на 2 МБ с нашего же домена (338 тысяч словоформ). Нет файла или нет сети — словечки просто не считаются, остальное работает.
ZIP без распаковки
С архивом ВКонтакте главная проблема — память телефона, а не скорость. Читать весь zip ради одного диалога нельзя, поэтому:
Хвост файла читаем через
blob.slice()и находим оглавление архива, центральный каталог. Для моих 35 тысяч файлов это порядка четверти секунды.Показываем список диалогов. Этот воркер живёт, пока человек выбирает, и заодно считает «топ собеседников».
Страницы выбранного диалога читаем точечно, тем же
slice(), и распаковываем встроеннымDecompressionStream('deflate-raw'). Всё остальное в архиве остаётся сжатым на диске.
Для архивов ВК лимита в 150 МБ не было и раньше: из них читается один диалог. Про zip и windows-1251 подробнее — в моей прошлой статье про книгу.
Файл любого размера
С result.json Telegram так не получалось: это один JSON, и вырезать из него кусок нельзя, пока не прочитаешь. Мы переписали чтение так, чтобы в памяти всегда лежало одно сообщение, а не весь файл.
Сканер верхнего объекта. Файл приходит кусками из Blob.stream(). Сканер идёт по символам и помнит только состояние: внутри ли строки, на какой глубине скобок, где граница текущего значения. Значения ключей верхнего уровня он делит на три режима:
export type ValueMode = /** Пропустить, не копя в памяти. */ | 'skip' /** Отдать целиком текстом (короткие значения: name, type, id). */ | 'capture' /** Значение — массив: отдавать его элементы-объекты по одному. */ | 'elements';
Массив messages идёт в режиме elements: каждое сообщение выходит наружу текстом, и дальше его разбирает обычный JSON.parse, один раз и на крошечный объект. Бессмысленно писать парсер чисел и строк заново, когда нужны только границы значений.
Тот же результат. Построитель чата у потокового и обычного разбора общий, поэтому на выходе одно и то же. Тесты сравнивают их через toEqual и режут вход на куски от одного символа до миллиона, с юникодом и экранированием посередине. Старые экспорты без date_unixtime читаются вторым проходом.
Каналы и боты отклоняются сразу, как только прочитан type, не дочитывая файл. Полный экспорт аккаунта — по ключу chats, тоже на первых байтах.
Zip и WhatsApp. Для zip сжатые блоки по 4 МБ подаются в DecompressionStream по мере чтения результата, потолка на размер записи нет. Для текста WhatsApp режем куски на строки, и самая вредная ошибка тут — пара CR LF, порванная на границе двух кусков.
Результат на синтетике: 500 тысяч сообщений, 197 МБ — 5,7 секунды в браузере. Тяжёлый случай из нашего журнала: 800 тысяч сообщений с длинными полями про медиа, 1,3 ГБ. Node читает его с пиком памяти около 0,45 ГБ, а Chrome доходит до экрана «Почти готово» за 18 секунд (эти два замера из журнала разработки, не из сегодняшнего прогона).
Как устроена приватность — не на словах
«Мы не храним ваши данные» пишут все. Нам хотелось, чтобы нарушить это обещание было трудно даже нам самим.
1. Текст из переписки — отдельный тип. Любая цитата длиннее слова получает брендированный тип, и получить его можно только через одну функцию:
declare const privateBrand: unique symbol; /** Текст из переписки. Получить можно только через makeExcerpt; в ChatReport такого типа быть не должно. */ export type PrivateText = string & { readonly [privateBrand]: true };
А рядом — проверка на уровне типов. Если кто-то добавит в ChatReport поле с PrivateText, сборка упадёт:
const reportHasNoPrivateText: Exactly<ContainsPrivate<ChatReport>, false> = true;
2. «Канарейка». Генератор синтетических чатов умеет подмешивать в переписку заранее известные фразы, телефон и ссылку-приглашение. Тест разбирает такой чат и проверяет, что в JSON отчёта нет ни одной из них, а в «личных моментах» (их видит только сам человек, по нажатию) они есть. Так проверяется и то, что приватность не сломана, и то, что тест не пустой.
3. CSP не даёт ходить никуда, кроме своего домена. Вот заголовок с боевого сайта:
default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; frame-ancestors 'none'; base-uri 'self'; form-action 'self' https://securepay.tinkoff.ru
Никаких сторонних счётчиков, шрифтов с CDN и рекламных пикселей: аналитика у нас своя, на нашем же домене. Даже если в зависимость завтра проберётся что-то любопытное, отправить данные на чужой адрес браузер ему не даст. Единственный внешний адрес — страница оплаты банка в form-action.
Оговорка, без которой разговор был бы нечестным: от нашего собственного сервера CSP не защищает, connect-src 'self' разрешает запросы к нему. Поэтому важен следующий пункт.
4. Что уходит на сервер. Вот вкладка Network, пока считался чат на 92 тысячи сообщений:

Разберём, чтобы ничего не оставалось тёмным.
Шаги пути по сайту (
/api/steps): имя шага («открыл сайт», «выбрал файл», «посчитал»), тип файла, тип чата — личный или группа, откуда пришёл. Ни адреса страницы, ни текстов, ни размеров в точных числах.Два хеша чата (
/api/peer-access). Сразу после расчёта браузер спрашивает сервер, не купил ли этот чат собеседник и не открыл ли он его вам. Сообщений и имён в хешах нет. Первый — PBKDF2 на 210 тысяч итераций: он нарочно медленный, потому что вход состоит из коротких ID, а быстрый хеш от них перебрали бы очень быстро. Второй — обычный SHA-256 от прежнего формата, в который входит дата первого сообщения; он нужен, чтобы открывались старые покупки. Честно: если известны ID обоих участников, перебор возможен и у PBKDF2, просто очень дорогой.Если человек платит — почта для чека; саму оплату принимает банк на своей странице.
Если человек вошёл и включил хранение отчётов — сам отчёт. Числа лежат открыто, а имена, слова, даты, несколько сообщений для карточек и фрагменты для бумажной книги зашифрованы (AES-GCM, ключ выводится из секрета). Ключ хранится в аккаунте, чтобы отчёты открывались на любом устройстве, где вы вошли. Значит, технически сервер мог бы его применить, и несколько цитат человека у нас в таком случае лежат. Раньше ключ был только у владельца, но «открыл отчёт на новом телефоне без QR-кода и пересылки ключей» оказалось для людей важнее, и мы выбрали удобство. Это не end-to-end, и об этом прямо сказано в согласии на хранение. Файл экспорта и переписка целиком не уходят всё равно, а вход и хранение человек включает сам. На сервере храним только сами ключи шифрования и ничего больше.
Если попросили напомнить в мессенджере — время и куда написать.
5. Офлайн. После первого открытия всё нужное для расчёта лежит в кэше service worker, так что сайт работает без сети. Это самое простое доказательство из всех.
Как проверить самому
Это та часть, ради которой всё и затевалось: проверка, не требующая нам верить.
Откройте страницу и дождитесь полной загрузки.
Включите авиарежим или выдерните кабель.
Выберите файл экспорта.
Итоги посчитаются: для расчёта сеть не нужна.
Авиарежим доказывает, что расчёт идёт на устройстве. Что после него ничего лишнего не уходит, видно во вкладке Network в DevTools: включите запись, загрузите файл и посмотрите на запросы и их тела. Как раз с авиарежимом на iPhone мы и намучились: страница там не знает, что сети нет, об этом я писал отдельной статьёй.
Что не получилось (пока)
Беседа на 194 тысячи — 11 секунд. Разбор HTML ВКонтакте и метрики — два основных кандидата на ускорение.
Телефоны. Все замеры выше сделаны на настольном процессоре, пик памяти вкладки на телефоне я не мерил. Потоковое чтение требует
Blob.stream()иDecompressionStream, то есть Safari 16.4 и новее; как это выглядит на старом Android, я не знаю.Полный экспорт аккаунта Telegram (все чаты одним файлом) пока не поддерживается: адаптер узнаёт его по
chats.listи просит экспортировать один чат.
Итог
Если коротко: одноразовый воркер, чтение файла кусками, один проход по строкам, числовые массивы вместо строк, бюджет скорости с журналом, приватность, закреплённая типами и тестом-«канарейкой», и CSP, который не даёт ничему утечь на чужой адрес. Реальные чаты считаются за секунды, самая большая из проверенных бесед на 194 тысячи — за 11, синтетические 500 тысяч — за 5,7. Файл с перепиской при этом на сервер не уходит.
Буду рад вопросам и критике. Мне особенно интересно:
Как бы вы доказали пользователю, что данные не уходят? Авиарежим и вкладка Network — это то, что придумали мы. Есть ли что-то убедительнее?
Видите ли дыру в нашей модели? Мы осознанно оставили хеш чата и ключ в аккаунте, но только их. Если считаете, что это зря, расскажите почему.
Потоковый разбор JSON. Мы написали свой сканер границ, а содержимое отдаём
JSON.parse. Есть ли способ проще или быстрее, например готовая библиотека, которую вы бы взяли?

