Почему я не стал делать мобильные приложения, а собрал одно (PWA) на все платформы

от автора

Когда продукт должен работать и на телефоне, и на планшете, и на ПК, и на маке, путей два. Первый по учебнику: нативка под iOS, нативка под Android, отдельный веб под десктоп, и дальше живёшь с тремя кодовыми базами, тремя релизными циклами и модерацией в сторах. Второй: одно сайт-приложение (SPA плюс PWA), которое ставится на домашний экран и работает везде одинаково.

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

Если коротко

·         Один SPA плюс PWA (React, TypeScript, Vite, Service Worker) на все платформы. Нативных приложений нет, и это осознанный выбор, а не «не успели».

     Главная выгода: одна кодовая база и мгновенные обновления через Service Worker. Без релизных циклов в сторах, без модерации, без залипшего старого билда.

     Деньги: нет комиссий магазинов, нет 30% сверху, оплата идёт напрямую рублями.

     Честный минус: PWA на iOS исторически урезаннее нативного (пуши, фон), и приходится объяснять пользователю «добавь на экран».

    Грабли прода: стратегия кэширования в Service Worker, баг на старых iPhone, который ловится только на железе, тюнинг TLS ради холодного старта.

Почему я отказался от нативки

Почему я отказался от нативки

Почему я отказался от нативки

Тут важно отделить эмоцию «нативное круче» от продуктовой математики. Что перевесило.

Три кодовые базы это три раза баги, три раза фичи, три раза регрессии. Любая новая функция без нативных приложений выкатывается один раз. С нативом это Swift, Kotlin и веб, каждая среда со своими подводными камнями. Для одного человека это не «дороже на 30%», а «медленнее в разы».

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

Комиссии. 15-30% с платежей это прямой вычет из маржи. И «залипший старый билд», классическая боль мобайла, когда часть аудитории месяцами сидит на старой версии. В PWA версия кэша явная и бампается каждый деплой.

Критерий

3 нативных + веб

Один SPA + PWA

Кодовых баз

3-4

1

Выкатка фикса

часы-дни (ревью)

минуты

Комиссия за платёж

15-30%

0 (прямой эквайринг)

Старые билды у юзеров

живут долго

бампается каждый деплой

Доступ к нативным API

полный

ограниченный (особенно iOS)

Установка

из стора

«добавь на экран»

————————————————————

Что я выиграл

Скорость поставки. Цикл «нашёл, починил, выкатил» в минутах. Деплой это пересборка и выкладка статики за nginx, никакой очереди на ревью. Контроль над обновлениями: Service Worker гарантирует, что юзер видит ту версию, что я выкатил, а не застрявшую в HTTP-кэше браузера. Ноль комиссий магазина. И один адрес вместо четырёх дистрибутивов: поделиться продуктом это ссылка, а не «скачай из App Store, Google Play, а на десктопе вот тут».

————————————————————

Что я потерял

Не буду продавать PWA как серебряную пулю, у выбора есть цена. iOS исторически режет PWA: web push появился поздно и работает с оговорками, фон слабый. «Добавь на экран» приходится объяснять, привычной кнопки «Установить» из стора нет. Витрины стора как канала органики тоже нет, весь трафик добываешь сам. И доступ к нативным API уже. Вывод простой: PWA выигрывает там, где продукт это инструмент, и проигрывает там, где он про нативный UX, фон и пуши.

————————————————————

Три грабли, которые подсветил этот выбор

Код упрощён и почищен.

Кэширование в Service Worker это не «закэшируй всё». Наивный SW, который кэширует всё подряд, выдаёт ровно ту боль, от которой я бежал, залипший билд. Рабочая схема: разные стратегии под разные ресурсы.

self.addEventListener(‘fetch’, e => {
  const url = e.request.url;
  if (isHashedAsset(url)) e.respondWith(cacheFirst(e.request));    // index-*.js, имя меняется при билде
  else if (isAppShell(url)) e.respondWith(networkFirst(e.request)); // index.html, version.json
  else if (isApi(url)) return;                                       // живые данные, не кэшируем
});

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

const CACHE_VERSION = ‘cm-cache-2026-06-02’;
self.addEventListener(‘activate’, e => e.waitUntil(
  caches.keys().then(ks => Promise.all(
    ks.filter(k => k !== CACHE_VERSION).map(k => caches.delete(k))))
));

Баг на старых iPhone, виден только на железе. Симптом обманчивый: «на старом айфоне половина не работает», а на эмуляторе и новых устройствах идеально. Виновник регэксп с lookbehind. Safari завёз его только в 16.4, а на iOS 15 и 16.0-16.3 это ошибка парсинга, которая кладёт весь модуль целиком, не «фича не работает», а «модуль не загрузился».

// крах на Safari < 16.4:
text.replace(/(?<=\s)#/g, fn)
// фикс, ручная проверка соседнего символа по offset:
text.replace(/#/g, (m, i) => isSpace(text[i-1]) ? fn() : m)

Урок прямо в тему статьи. Кросс-платформенность веба не отменяет зоопарк рантаймов. «Один билд на всё» не значит «один движок на всё», Safari на старом iPhone это отдельная среда, и баги в ней ловятся только на реальном железе. С тех пор lookbehind под старые Safari у меня в табу и покрыт тестами.

Тюнинг TLS ради холодного старта. Жалоба «грузится медленно» оказалась не про размер бандла, а про сервер. На холодном соединении упиралось в TLS-рукопожатие на nginx. Включил сессионный кэш и OCSP stapling, холодное открытие заметно ускорилось:

ssl_session_cache   shared:SSL:10m;
ssl_session_tickets on;
ssl_stapling        on;
ssl_stapling_verify on;

Мораль для PWA: первое впечатление это первое соединение. Если PWA единственная точка входа на все платформы, скорость холодного коннекта важнее, чем кажется, и это зона инфраструктуры (nginx, HTTP/2, TLS), а не только фронтенда.

————————————————————

Кому подходит, а кому нет

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

————————————————————

Вывод

Отказ от нативки в пользу одного SPA плюс PWA это не «сэкономил на разработке», а продуктовое решение с понятным профилем плюсов и минусов. Я получил одну кодовую базу, мгновенные обновления, ноль комиссий и один адрес на все устройства. Заплатил урезанными возможностями PWA на iOS и необходимостью объяснять «добавь на экран». Для инструмента, который меняется каждый день, размен выгодный. Но не универсальный, для продукта с тяжёлым фоном и пушами я бы решал иначе.

Если ты строил PWA-first продукт или наоборот отказался от PWA в пользу натива, интересно сравнить опыт в комментах. Где у вас PWA на iOS реально упёрлась в потолок и как вы решали онбординг «добавь на экран», чтобы юзер не оставался во вкладке браузера.

ссылка на оригинал статьи https://habr.com/ru/articles/1045038/