TL;DR: Меня зовут Ярослав Боев, в сети — SwairIt. Я делаю Persona — личного ИИ-ассистента с приватной памятью, который можно поднять у себя. Пару недель я жил с ощущением «сайт какой-то вязкий», и оно было правильным: страницы открывались за 2,1 секунды. Причём сервер отдавал HTML за 30 миллисекунд. Виноватыми оказались две вещи, и обе написал я сам: аутентификация, которая делала запись в SQLite на каждый CSS-файл, и Tailwind, который компилировал стили в браузере у каждого посетителя. После починки — 0,9 секунды.

А потом я сел делать скриншоты для этой самой статьи и за двадцать минут нашёл ещё два бага, которые спокойно жили в проде: тему, которая на части машин становилась белым по белому, и страницу настроек, которая просто не показывала ни одной из девяноста ссылок. Ниже — как я всё это искал, чем мерил, почему первый час искал не там и как чуть не уронил чат, исправляя найденное. Код открыт: github.com/SwairIt/persona.


Что за сайт я ускорял

Чтобы дальше было понятно, за что именно платили те 45 запросов на страницу.

Persona — личный ИИ-ассистент, который живёт на вашем железе. Не «чат с нейросетью в браузере», а кабинет, который помнит вас: разговоры, факты, связи между ними. Модель подключается своя — ключ к облачному провайдеру или локальная Ollama.

Лендинг. Чёрная дыра на фоне — WebGL-сцена на three.js, не гифка.
Лендинг. Чёрная дыра на фоне — WebGL-сцена на three.js, не гифка.

Лендинг. Чёрная дыра на фоне — WebGL-сцена на three.js, не гифка.

Второй экран: стек одной строкой и главное обещание.
Второй экран: стек одной строкой и главное обещание.

Второй экран: стек одной строкой и главное обещание.

Внутри кабинета, если коротко по главному:

  • Чат с памятью. Перед ответом ассистент подтягивает релевантное из всех прошлых бесед — по умолчанию через FTS5 с ранжированием bm25, опционально гибридный режим с векторным поиском. Плюс отдельный блок личных фактов о пользователе.

  • Граф памяти. Промпты, ответы, сущности и связи между ними — живая force-directed раскладка, а не картинка.

  • Захват дня. Скриншоты рабочего экрана с дедупликацией по хэшу, OCR, распознавание активного окна, аудио. Всё с квотами, тихими часами, блоклистом приложений и retention.

  • Сводки и брифинг. Часовые карточки, дневные пины, недельные сводки, проактивные карточки «что было важного».

  • Голос. Hands-free разговор с орбом-микрофоном, VAD, TTS.

  • Навыки и автоматизация. Наборы инструкций из GitHub (SKILL.md), браузер-агент, MCP-рантайм.

  • Vault. Зашифрованные заметки; секреты участников шифруются в покое.

  • Мультипользовательский режим. Регистрация открыта, каждый приходит со своим ключом. Данные захвата — только у владельца инстанса, участник видит строго своё.

  • Блог, аналитика, аудит-лог, резервные копии, Telegram, интеграция с Алисой, .ics-экспорт напоминаний.

Чат.
Чат.

Чат.

Граф памяти: 12 ответов, 22 чата, 20 сущностей — и связи между ними.
Граф памяти: 12 ответов, 22 чата, 20 сущностей — и связи между ними.

Граф памяти: 12 ответов, 22 чата, 20 сущностей — и связи между ними.

Дашборд «Сейчас»: последний скрин, счётчики дня, фокус-сессия, напоминания. Обновляется сам через HTMX.
Дашборд «Сейчас»: последний скрин, счётчики дня, фокус-сессия, напоминания. Обновляется сам через HTMX.

Дашборд «Сейчас»: последний скрин, счётчики дня, фокус-сессия, напоминания. Обновляется сам через HTMX.

Пять тем

Тема переключается в настройках и хранится за пользователем: у участника — в своей таблице, чтобы не перетереть настройки владельца.

dark
dark

dark

light
light

light

persona — фирменная фиолетовая
persona — фирменная фиолетовая

persona — фирменная фиолетовая

cosmos — с живой 3D-сценой за кабинетом
cosmos — с живой 3D-сценой за кабинетом

cosmos — с живой 3D-сценой за кабинетом

cosmos-dark — та же сцена, приглушённая
cosmos-dark — та же сцена, приглушённая

cosmos-dark — та же сцена, приглушённая

Сколько всего настроек

Считаю честно, из кода, а не на глаз.

Хаб настроек — 12 категорий и 90 отдельных страниц для владельца инстанса. Участник видит урезанный каталог: 6 категорий, 16 страниц — только то, что относится к его ассистенту и его аккаунту, без единой owner-only ссылки.

Тот самый хаб: 12 категорий, 90 ссылок.
Тот самый хаб: 12 категорий, 90 ссылок.

Тот самый хаб: 12 категорий, 90 ссылок.

Если считать не страницы, а сами переключатели и поля — 156 уникальных настроек в 99 шаблонах. На моей живой базе прямо сейчас лежит 163 сохранённых ключа конфигурации.

Для масштаба, остальные цифры проекта на момент статьи: 428 модулей роутов, 383 шаблона, 235 миграций базы, 181 файл тестов.

Вот всё это добро я и открывал по две секунды.


Симптом, который невозможно нормально сформулировать

Знакомое чувство: сайт не сломан. Он открывается. Ничего не падает, в логах чисто, ошибок нет. Просто ты кликаешь по вкладке и успеваешь заметить, что кликнул.

Это худший вид бага, потому что его нельзя завести в трекер. «Медленно» — это не баг-репорт. Пока ты не превратил ощущение в число, у тебя нет ни проблемы, ни решения.

Поэтому первое, что я сделал — померил самое очевидное:

curl -o /dev/null -w "ttfb=%{time_starttransfer} total=%{time_total}\n" \
     http://127.0.0.1:8000/landing
# ttfb=0.026  total=0.027

26 миллисекунд. Сервер отвечает мгновенно. И вот тут я сделал первую ошибку.

Ошибка №1: я решил, что понял

Раз бэкенд быстрый — значит виноват фронтенд. Логично же? Я пошёл смотреть, сколько всего грузит страница, нашёл там 600 КБ three.js и полез оптимизировать картинки.

Полчаса в никуда.

Проблема в том, что /landing — это лендинг для незалогиненных. А «вязко» мне было внутри кабинета, где я сижу залогиненным. Я померил не ту страницу, обрадовался хорошей цифре и построил на ней теорию.

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

const m = await page.evaluate(() => {
  const nav = performance.getEntriesByType('navigation')[0];
  const paint = {};
  performance.getEntriesByType('paint').forEach(e => paint[e.name] = Math.round(e.startTime));
  return {
    ttfb: Math.round(nav.responseStart),
    fcp: paint['first-contentful-paint'],
    dcl: Math.round(nav.domContentLoadedEventEnd),
    reqs: performance.getEntriesByType('resource').length,
  };
});

И вот что он показал для /chat:

{ ttfb: 322, fcp: 1320, dcl: 2092, reqs: 45 }

Две вещи сразу выбиваются.

Первая: TTFB 322 мс вместо 26. Та же машина, тот же сервер, разница только в том, что у меня в браузере есть кука сессии. Десятикратная разница из-за куки — это не «ну бывает», это след.

Вторая: 45 запросов на одну страницу. И это на localhost, где сеть бесплатная. У живого человека каждый такой запрос — это ещё и round trip до сервера.

Замер, который всё объяснил

Дальше самая полезная минута за весь день. Я взял один-единственный статический файл — крошечный csrf.js — и дёрнул его десять раз с кукой и десять раз без:

# с кукой сессии
for i in $(seq 1 10); do
  curl -s -b cookies.txt -o /dev/null -w "%{time_total} " \
       http://127.0.0.1:8000/static/csrf.js
done
# 0.057 0.067 0.042 0.061 0.165 0.052 0.061 0.051 0.054 0.049

# без куки
for i in $(seq 1 10); do
  curl -s -o /dev/null -w "%{time_total} " \
       http://127.0.0.1:8000/static/csrf.js
done
# 0.011 0.011 0.031 0.015 0.013 0.026 0.024 0.013 0.012 0.026

55 мс против 14 мс. За один и тот же файл с диска. Единственная разница — наличие куки.

Сорок миллисекунд накладных расходов на каждый статический файл. А страница тянет их около сорока штук. Сорок раз по сорок миллисекунд — это полторы секунды, которые сервер тратит вообще не на то, чтобы отдать файл.

Вот теперь у меня был не «сайт тормозит», а конкретное число и конкретное место.

Кто это делал

В Persona есть middleware-гейт: он смотрит, кто пришёл, и решает, пускать ли дальше. Выглядел он примерно так:

async def dispatch(self, request, call_next):
    path = request.url.path

    if path == "/" or _is_public_path(path):
        # публичный путь — доступ не проверяем,
        # но личность всё равно резолвим
        return await self._with_identity(request, call_next, public=True)
    ...

async def _with_identity(self, request, call_next, *, public):
    token = request.cookies.get(SESSION_COOKIE_NAME)
    session = await verify_session(token) if token else None
    ...

/static/ лежит в списке публичных путей. То есть доступ для него не проверяется — но verify_session() всё равно вызывается. А внутри verify_session():

# Fire-and-forget last_seen bump; failure must not break auth.
try:
    await conn.execute(
        "UPDATE auth_session SET last_seen_at = ? WHERE token = ?",
        (now, token),
    )
    await conn.commit()
except Exception as exc:
    log.debug("auth.session.last_seen_update_failed", error=str(exc))

Вот оно. SELECT, UPDATE и COMMIT в SQLite на каждый CSS-файл, каждую иконку, каждый скрипт.

На Windows с включённым WAL коммит — это не «записать в память», это заставить диск подтвердить запись. Десятки миллисекунд. И пока идёт коммит, остальные запросы стоят в очереди на запись.

Сорок файлов на странице — сорок коммитов. Все сорок пишут в одну и ту же строчку один и тот же таймстамп, потому что происходят в одну и ту же секунду.

Почему это вообще было написано

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

last_seen_at нужен для странички со списком активных сессий — той самой, где пишут «последняя активность: 2 минуты назад» и дают кнопку «выйти со всех устройств». Плюс по нему же считается протухание сессии по бездействию.

Логика была: обновляем при каждой проверке — значит отметка всегда точная. И это правда. Проблема не в идее, а в том, что «каждая проверка» на деле означала «каждый файл на странице», а я представлял себе «каждый переход по сайту».

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

Фикс: два места, обе однострочные по сути

Первое. Статика уходит из гейта до всякой работы с базой:

# Статика уходит раньше всех проверок — и раньше резолва личности.
# verify_session делает SELECT + UPDATE + COMMIT, а страница кабинета
# тянет ~40 файлов из /static. Замер: тот же GET /static/csrf.js —
# 55 мс с кукой против 14 мс без неё.
#
# Права тут не меняются: /static/ и так в _PUBLIC_PREFIXES, решение
# доступа для него всегда было «пропустить». Личность файлам не нужна:
# их отдаёт StaticFiles, а не шаблон, поэтому request.state никто не читает.
if _is_asset_path(path):
    return await call_next(request)

Отдельно проверил, что под /static/ не живёт ни одного роута, который рендерит шаблон — иначе я бы тихо сломал определение «кто смотрит страницу». Там только StaticFiles и отдача sw.js файлом. Чисто.

Второе. Сама запись стала происходить не чаще раза в минуту:

_LAST_SEEN_RESOLUTION = timedelta(seconds=60)

def _last_seen_is_stale(value, now) -> bool:
    """True, когда отметку пора обновить.

    NULL и неразбираемая дата считаются устаревшими: лучше один лишний
    коммит, чем строка, которая никогда не обновится и однажды выселит
    живую сессию как простаивающую.
    """
    if not value:
        return True
    try:
        seen = datetime.fromisoformat(str(value))
    except ValueError:
        return True
    if seen.tzinfo is None:
        seen = seen.replace(tzinfo=timezone.utc)
    return (now - seen) >= _LAST_SEEN_RESOLUTION

Здесь важно было не сломать выселение по бездействию — оно считается по тому же полю. Посчитал: окно бездействия по умолчанию 14 дней, погрешность от троттлинга — до минуты. Минута внутри четырнадцати дней роли не играет. А «последняя активность» в списке сессий теперь отстаёт максимум на минуту, что человек не заметит.

Единственное, чего я не стал делать — выкидывать last_seen_at совсем. Заманчиво, но это живая функция безопасности, а не украшение.

Тест я написал раньше фикса

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

@pytest.mark.asyncio
async def test_static_request_does_not_touch_session_table(signed_in) -> None:
    """Статика с кукой сессии не переписывает last_seen_at."""
    client, token = signed_in
    stale = datetime.now(timezone.utc) - timedelta(days=1)
    await _set_last_seen(token, stale)

    response = await client.get("/static/csrf.js?v=2.35.0")

    assert response.status_code == 200
    assert await _last_seen(token) == stale.isoformat()

Запустил до правки — упал. Это и есть смысл упражнения: я своими глазами увидел, что тест ловит именно ту проблему, а не проходит просто так.

Рядом положил второй, зеркальный — что обычная страница активность по-прежнему записывает. Без него первый тест проходил бы и в случае, если бы я случайно выключил last_seen_at вообще.

Второй тормоз: стили, которые собирались заново у каждого посетителя

С базой разобрались, TTFB упал. Но FCP всё ещё был около секунды, а первая отрисовка на localhost за секунду — это много.

В <head> у меня висело вот это:

<script src="/static/vendor/tailwind-play.js"></script>

Это Tailwind Play CDN — 407 КБ JIT-компилятора. Он скачивается, обходит DOM, смотрит, какие классы вы использовали, генерирует под них CSS и вставляет <style> в страницу.

И делает это на каждой загрузке каждой страницы. Не один раз при сборке проекта — каждый раз, в браузере пользователя, на его процессоре.

Плюс на теге нет defer. Значит парсер документа встаёт и ждёт, пока эти 407 КБ скачаются и выполнятся.

Взял его и заблокировал в браузере, чтобы понять цену:

FCP

DOMContentLoaded

как было

1320 мс

2092 мс

без tailwind-play.js

884 мс

1496 мс

436 миллисекунд первой отрисовки — на мощном сервере, с тёплым кэшем, без сетевой задержки. На телефоне через мобильный интернет — считайте сами.

Смешно, что за год до этого я уже переносил Tailwind с зарубежного CDN к себе на сервер, потому что на российских сетях cdn.tailwindcss.com подтормаживал. Тогда я решил проблему доставки файла и вообще не задумался, зачем этот файл нужен.

Починка скучная и правильная — собрать CSS заранее:

npx tailwindcss -c tailwind.config.js -i ops/tailwind/input.css \
    -o app/web/static/vendor/tailwind-built.css --minify

407 КБ JavaScript с компиляцией в рантайме → 96 КБ обычного CSS (~24 КБ в gzip), который браузер кэширует на год и больше про него не вспоминает.

Версию Tailwind взял ровно ту, что была зашита в Play CDN — 3.4.17. Не хотелось вместе с ускорением получить ещё и переезд на другой набор утилит.

Ловушка, на которой я почти попался

Перед заменой я решил проверить, куда именно Play CDN кладёт сгенерированный <style>. Просто из любопытства.

const order = await page.evaluate(() =>
  Array.from(document.head.children).map((e, i) => {
    if (e.tagName === 'STYLE') return i + ': STYLE len=' + e.textContent.length;
    if (e.tagName === 'LINK' && e.rel === 'stylesheet') return i + ': LINK ' + e.href.split('/').pop();
    return null;
  }).filter(Boolean)
);
22: LINK app.css
23: LINK persona_theme.css
...
33: LINK copilot.css
55: STYLE len=18368   ← вот он

Скрипт стоял в <head> выше всех моих <link>. А стиль, который он генерирует, оказывается ниже их всех — потому что вставляется не в момент подключения скрипта, а когда скрипт отработал.

При одинаковой специфичности выигрывает то правило, что ниже. То есть утилиты Tailwind у меня всё это время перебивали app.css и файлы тем.

Поставь я <link> на новый собранный CSS туда, где раньше стоял <script> — то есть в самый верх, как выглядит логично, — и каскад бы перевернулся. Не с ошибкой в консоли, а тихо: где-то поехали бы отступы, где-то цвет. Половину я бы нашёл через неделю, половину — от пользователей.

Поэтому ссылка стоит последней среди стилей, и над ней висит комментарий, почему её нельзя двигать вверх.

Как я проверял, что не сломал вёрстку

Замена Tailwind трогает вообще все страницы. Проверять их глазами по одной — так себе план, что-нибудь да пропустишь.

Сделал скриншот-дифф. До правки прогнал по 14 страницам (публичные + кабинет) и сохранил снимки. После правки — то же самое. Потом попиксельно сравнил через pixelmatch:

    3307 px (0.255%)   owner_graph.png
      63 px (0.005%)   owner_blog.png
      23 px (0.002%)   anon_landing.png
       8 px (0.001%)   owner_now.png
       8 px (0.001%)   owner_settings.png
       ...
       0 px (0.000%)   anon_auth_login.png
       0 px (0.000%)   anon_auth_signup.png

Логин и регистрация — ноль отличий, вообще ни одного пикселя. По 8 пикселей на остальных страницах — это номер версии в шапке поменялся с v2.35.0 на v2.36.0.

Единственная заметная цифра — граф памяти. Открыл картинку с диффом: разница только в положении узлов, вся обвязка страницы совпадает пиксель в пиксель. Там force-directed раскладка, она между запусками сама по себе слегка пляшет.

Эти полчаса на скриншот-дифф — лучшее вложение за весь день. Я перестал гадать, сломалось что-нибудь или нет.

Побочная находка: 18 классов, которые никогда не работали

Собранный Tailwind, в отличие от рантайм-компилятора, прощает меньше: он знает только те классы, которые нашёл в файлах при сборке. Значит забыл пересобрать — класс молча не применился.

Написал тест, который вытаскивает классы из шаблонов и проверяет, что они есть в собранном CSS. И на первом же запуске он выдал 20 штук, которых нет:

bg-accent-700  bg-accent-900/20  border-ink-600  focus:border-accent-300
hover:text-accent-200  text-accent-300  bg-grid  bg-mesh  ...

Сначала подумал, что сломал сборку. Полез разбираться.

Два из них — bg-grid и bg-mesh — оказались моими собственными классами из landing/style.css. К Tailwind отношения не имеют.

А остальные восемнадцать ссылались на оттенки, которых в моей палитре нет. У меня заведены accent 400/500/600 и ink 700–950. А в шаблонах жили accent-200, accent-300, accent-700, ink-600.

То есть они не сломались при переезде. Они никогда не работали — Play CDN тоже собирал стили по этому же конфигу и тоже ничего для них не генерировал. Просто раньше об этом никто не сообщал, а теперь стало видно.

Тест я после этого уточнил: он отличает «забыли пересобрать» от «такого класса не существует в принципе», иначе бы падал вечно. А сам список мёртвых классов лежит в задачах — это уже не про скорость.

Отдельно проверил, что тест реально ловит то, ради чего написан: вписал в шаблон bg-lime-300, которого в сборке нет, — упал. Убрал — прошёл. Тест, который ни разу не падал, ничего не гарантирует.

Пока делал скриншоты для этой статьи, нашёл ещё два бага

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

Баг №1: тема, которая становилась белой на белом

Снял космическую тему для раздела про темы, открыл картинку — а заголовок страницы не читается. Белый текст на белом фоне.

Первая мысль была правильная и неприятная: «это я сломал Tailwind». Полез проверять — оказалось, нет.

В теме есть правило для фона кабинета:

.theme-cosmos body{
  background: /* градиенты */ #030309 !important;
  background-color:#030309 !important;
}

А ниже, среди правил для панелей — вот это:

.theme-cosmos .bg-zinc-50{ background-color: transparent !important; }

У <body> в шаблоне стоит класс bg-zinc-50. Специфичность двух классов (0-2-0) больше, чем класса и элемента (0-1-1). То есть второе правило перебивало первое, и у страницы не оставалось никакого непрозрачного фона — ни у html, ни у body.

Пока WebGL-сцена рисуется, этого не видно: она закрывает весь экран. Но стоит ей не отрисоваться — нет аппаратного ускорения, GPU в блоклисте, софтверный рендер — и сквозь прозрачный body проступает белый холст браузера. А текст в этой теме светлый.

Проверил инструментально, а не на глаз:

const cv = document.querySelector('#cosmos-scene');
const tmp = document.createElement('canvas');
tmp.width = 60; tmp.height = 40;
tmp.getContext('2d').drawImage(cv, 0, 0, 60, 40);
// считаем пиксели с alpha > 8
// → { непрозрачныхПикселей: 0, всегоПикселей: 2400 }

Ноль. Сцена не нарисовала ничего, и запасного плана у страницы не было.

Фикс — один селектор:

.theme-cosmos .bg-zinc-50:not(body){ background-color: transparent !important; }

Панелям прозрачность по-прежнему нужна, а body теперь оставляет себе тёмную базу. Когда сцена работает — её не видно, она под ней. Когда не работает — страницу можно читать.

Баг №2: страница настроек, которая была пустой

Дальше я снимал хаб настроек — тот самый, с 90 ссылками. И получил пустую страницу: заголовок есть, поиск есть, категорий нет ни одной.

Каталог рисует Alpine, данные приезжают из шаблона:

x-data="settingsHub({{ categories_json | tojson }})"

Смотрю, что реально ушло в HTML:

x-data="settingsHub([{

И всё. Атрибут оборвался.

Причина в том, что tojson рассчитан на вставку внутрь <script>. Он экранирует <, >, & и одинарную кавычку — чтобы нельзя было закрыть тег или выйти из строки. А двойную не трогает. В JSON она стоит вокруг каждого ключа, и в атрибуте, ограниченном двойными кавычками, первая же такая кавычка этот атрибут закрывает.

Браузер получал огрызок, Alpine — синтаксическую ошибку, страница — пустой массив категорий. Ни красной строчки для пользователя, ни 500 в логах. Просто пусто.

Лечится добавлением | forceescape — он переводит кавычки в HTML-сущности, а браузер разворачивает их обратно при разборе атрибута. Что характерно, в соседнем шаблоне проекта этот приём уже стоял. Просто не везде.

Дальше я, конечно, полез искать то же самое по всем шаблонам

И вот тут была самая поучительная часть дня.

Написал тест, который ищет | tojson внутри HTML-атрибутов без forceescape. Он нашёл 24 места в 10 шаблонах. Я обрадовался, поправил все разом, перезапустил — и сломал чат. 42 ошибки в консоли, Unexpected token '&', страница не поднимается.

Потому что часть найденного была внутри <script>, а не в атрибуте. Мой поиск считал, что раз перед выражением стоит <, значит мы внутри тега. А в JavaScript символ < встречается сплошь и рядом — в сравнениях, в комментариях. Внутри скрипта &#34; никто не разворачивает, и правка превращала рабочий JS в мусор.

Откатил. Научил поиск двум вещам:

def _mask_scriptish(text: str) -> str:
    """Заменяем тела <script>/<style> пробелами, сохраняя длину."""
    return _SCRIPTISH.sub(lambda m: " " * len(m.group(0)), text)

И, что оказалось не менее важным, — различать кавычку атрибута:

def _enclosing_quote(text: str, position: int) -> str | None:
    """Какой кавычкой ограничен атрибут, внутри которого стоит позиция."""
    open_at = text.rfind("<", 0, position)
    if open_at < 0 or text.rfind(">", 0, position) > open_at:
        return None  # не внутри тега
    quote = None
    for ch in text[open_at:position]:
        if quote is None:
            if ch in "\"'":
                quote = ch
        elif ch == quote:
            quote = None
    return quote

Потому что tojson экранирует одинарную кавычку. Значит в x-data='{...}' он совершенно безопасен, и трогать там нечего. А половина найденных мест была именно такой.

После обеих поправок из 24 «находок» осталось 12 настоящих в 5 шаблонах: монитор системы, поиск, страница скриншота, переключатель модели, настройки памяти. Плюс хаб настроек. Все — сломаны одинаково и незаметно.

Мораль, которую я записал себе: массовая правка по результатам своего же поиска — это отдельный риск. Мой поиск дал 50% ложных срабатываний, и я успел на них наступить, потому что «ну тут же очевидно». Проверять надо не только то, что нашёл, но и сам способ, которым искал.

Проверка после фикса, в браузере, по каждой затронутой странице:

категории настроек   200  x-data:7  с данными:7   ссылок:179  ошибок JS:0
монитор системы      200  x-data:6  с данными:6   ссылок:85   ошибок JS:0
поиск                200  x-data:6  с данными:6   ссылок:87   ошибок JS:0
чат                  200  x-data:72 с данными:72  ссылок:110  ошибок JS:0

Для сравнения, хаб настроек до фикса: 7 ссылок и 989 символов текста. После: 179 ссылок и 5875 символов.


Цифры

Страница /chat, тот же браузер, та же машина, тёплый кэш:

было

стало

TTFB

213 мс

196 мс

First Contentful Paint

1028 мс

517 мс

DOMContentLoaded

1854 мс

886 мс

load

1888 мс

908 мс

На холодную (первый заход, пустой кэш) — было 2105 мс, стало 876 мс.

И контрольный замер, тот самый, с которого всё началось:

# /static/csrf.js, 10 запросов
до фикса:    с кукой ~55 мс   без куки ~14 мс
после фикса: с кукой ~15 мс   без куки ~15 мс

Разницы между «залогинен» и «не залогинен» для статики больше нет. Её и не должно было быть.

Что я из этого вынес

Ощущение «медленно» — это не данные. Пока не превратил в число, чинить нечего. Зато когда превратил — обычно сразу видно и место.

Мерь то, на что жалуешься. Я потерял полчаса, потому что померил лендинг, а тормозил кабинет. Хорошая цифра с не той страницы хуже, чем никакой: она даёт ложную уверенность.

Сравнение полезнее абсолютного числа. «Файл отдаётся за 55 мс» — ну и что, много это или мало? А «55 мс с кукой против 14 без» — это уже готовый ответ.

Смотри, сколько раз вызывается твой код, а не только что он делает. Ни один из тормозов не был ошибкой в логике. Запись last_seen_at правильная. Tailwind собирает стили правильно. Просто одно вызывалось в сорок раз чаще, чем я думал, а второе — на каждой загрузке вместо одного раза при сборке.

Проверяй, что не сломал, механически. Скриншот-дифф на 14 страниц занял полчаса и снял все вопросы. Просмотр глазами занял бы столько же и оставил бы сомнения.

Сделай скриншоты своего проекта. Серьёзно. Оба бага из предыдущего раздела я нашёл не когда искал баги, а когда фотографировал интерфейс для этой статьи. Ты открываешь страницы, которые обычно проскакиваешь на автопилоте, и смотришь на них как чужой человек. Дешевле любого QA.

Не доверяй собственному поиску по коду. Мой скрипт нашёл 24 «проблемы», из которых настоящими оказались 12. Я поправил все 24 разом и уронил чат. Массовая правка требует проверки не только результата, но и метода, которым ты составил список.

Ни одна из четырёх правок не была сложной. Все — по несколько строк, а одна вообще состоит из :not(body). Сложным было понять, куда эти строки положить, и на это ушёл весь день.


Код открыт: github.com/SwairIt/persona, AGPL-3.0. Репозиторий был закрытым — я не хотел выкладывать наружу мультипользовательский режим и шифрование данных участников, пока сам не проверю, что там нечего стыдиться. Перед тем как нажать «сделать публичным», прогнал правила своего же сканера секретов не по текущему дереву, как он умеет штатно, а по всей истории: 9685 объектов, 6095 текстовых версий файлов, 583 коммита. Сработало восемь раз — и все восемь оказались тестовыми фикстурами и примерами из документации, вроде того самого демонстрационного AWS-ключа, который Amazon сама печатает в своих гайдах. Ни .env, ни приватного ключа, ни файла базы в истории не было никогда. Рекомендую такую проверку всем, кто собирается открывать давно живущий репозиторий: секрет, удалённый следующим коммитом, остаётся в истории навсегда, и git log -p его не покажет — надо смотреть объекты.

Пишу я в паре с Claude Code и не делаю из этого секрета. Но сегодняшние правки — это не то, что можно попросить сгенерировать. Сначала надо было догадаться дёрнуть один файл с кукой и без. Всё остальное было уже следствием.

Персональные вопросы, разбор кода, «а у меня похожее» — велкам в комментарии.

— Ярослав Боев

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


  1. Keeper12
    29.08.2026 17:02

    Tailwind

    Выбрось бяку.


    1. Annsky
      29.08.2026 17:02

      Разверните свою мысль пожалуйста.


      1. Keeper12
        29.08.2026 17:02

        Если вы хотите инлайнить стили в элементы HTML, -- что уже достаточно странно, -- ни Tailwind, ни вообще CSS вам для этого не нужен.


        1. SwairIt Автор
          29.08.2026 17:02

          Утилитарные классы и инлайн-стили - это не одно и то же, и разница не вкусовая.

          style=“…” не умеет ни медиазапросы, ни псевдоклассы. У меня в шаблонах 477 классов с брейкпоинтами (md:, lg:), 1618 с hover: и 615 с focus: — 2710 мест, которые в атрибут style не переписываются в принципе. Пришлось бы всё равно заводить CSS, только теперь рядом с инлайном.

          Дальше вес. Собранный Tailwind на всё приложение (383 шаблона) - 96 КБ, 16 КБ в gzip: один запрос, кэш на год. Инлайн-стили не кэшируются никогда — они едут заново в каждом HTML-ответе и повторяются на каждом элементе.

          И безопасность. Инлайновые style= требуют style-src ‘unsafe-inline’ в CSP. У меня 430 таких атрибутов в 95 шаблонах, и именно они не дают закрыть эту дыру. Классы не требуют ничего. Так что «инлайнить вместо классов» — не упрощение, а ещё и шаг назад.

          С самой претензией к Tailwind я, кстати, наполовину согласен: в статье его как раз наполовину и выкинул - убрал рантайм-компилятор, который пересобирал стили в браузере у каждого посетителя. Осталась обычная статическая таблица стилей, к ней вопросов пока нет. Если есть конкретная - расскажите, послушаю


  1. Fox_exe
    29.08.2026 17:02

    А зачем мучить себя написанием запросов с Curl, когда время загрузки всего и вся можно посмотреть прямо в браузере, в Dev tools, который есть практически во всех браузерах ?

    делала запись в SQLite на каждый CSS-файл

    И тут мой мозг сломался... Имелось ввиду "на каждый запрос" (на каждую страницу)?
    Какбы CSS/JS/PNG/JPG/WEBP и прочая статика должна отдаваться серверов (Nginx/Apache2/etc..) напрямую, миную любую логику (php/python/c#/etc..)


    1. SwairIt Автор
      29.08.2026 17:02

      Про DevTools - я им и мерил, в статье это есть: performance.getEntriesByType(‘navigation’), оттуда FCP, DCL и «45 запросов на страницу». Он отвечает на вопрос «что грузится долго».

      curl понадобился для другого вопроса - «почему». Нужно было дёрнуть один и тот же файл десять раз с кукой сессии и десять раз без неё и сравнить медианы. В DevTools это неудобно: кэш, переиспользование соединения, и главное — одиночный замер ничего не значит. «csrf.js: 55 мс» само по себе не выглядит подозрительно. Подозрительным оно становится только рядом с «14 мс без куки».

      Про «на каждый CSS-файл» - нет, мозг не сломался, там буквально так. Не на страницу, а на каждый файл. Запрос к /static/whatever.css доходил до приложения, проходил через middleware аутентификации, тот дёргал verify_session(), а он делал SELECT + UPDATE + COMMIT. Сорок файлов на странице - сорок коммитов, все пишут один и тот же таймстамп.

      И вот тут вы правы, спасибо. Статику действительно должен отдавать веб-сервер до всякого питона. У меня она сейчас идёт через Starlette StaticFiles, то есть через весь ASGI-стек. Мой фикс убрал поход в базу (55 → 15 мс), но запрос по-прежнему будит Python. Отдать /static через nginx напрямую — правильный следующий шаг, оставшиеся 15 мс он тоже съест. Записал в список.


  1. Annsky
    29.08.2026 17:02

    Продолжайте. Изучайте программирование. 15 лет прекрасный возраст для этого.


    1. SwairIt Автор
      29.08.2026 17:02

      Спасибо! Собственно, ради этого статьи и пишу - за один сегодняшний тред уже узнал, что статику надо было сразу отдавать nginx-ом мимо питона. Сам бы дошёл до этого сильно позже.


  1. ToxaBes
    29.08.2026 17:02

    Возникает только один вопрос т.к. не совсем понятно: сколько вам лет?

    К остальному вопросов не возникает, это чистейший, незамутненный ИИ-слоп и возраст не является оправданием этому.

    Опустим то, что у вас "CSS-файлы пишут в базы данных" и постоянное упоминание вашего возраста, важно то как вы доносите свою мысль, потому что "Мне 15, код открыт" в контексте ИИ-слопной статьи означает что и код там такой же, навайбкоженый. Его (код) не касалась рука человека.

    Я написал этот комментарий не потому что я злобный старикан, а потому что хочу чтобы вы поняли почему вашу статью минусуют, сделали выводы и писали следующие статьи сами, своими словами (пусть и используя ИИ при их подготовке).

    Другими словами, дайте нам повод плюсовать вас не только за возраст.


    1. SwairIt Автор
      29.08.2026 17:02

      Да, по тексту справедливо, писал с ИИ.

      Не соглашусь только про “CSS-файлы пишут в базу”, там реально так было: запрос за файлом из /static/ доходил до питона, срабатывала проверка сессии, а она делала UPDATE и COMMIT. Тупо звучит, тупо и было


  1. savostin
    29.08.2026 17:02

    Правило простое - не отдавать статику (это то, что не зависит от конкретного пользователя) приложением. CDN. Раньше было модно советовать какой- нибудь cdnjs, но в сегодняшних реалиях уже зависит от юрисдикции вашей аудитории.


    1. SwairIt Автор
      29.08.2026 17:02

      Да, согласен. Причём с CDN уже обжёгся: раньше тянул tailwind и часть библиотек с их CDN, но на российских сетях они троттлились и сайт от этого тормозил ещё сильнее. Пришлось сложить всё к себе в /static. Как раз про юрисдикцию, да.

      Так что вариант остаётся один: отдавать самому, но nginx-ом, а не питоном. Этим и займусь