Почему ваше приложение тормозит и как мы это исправили с помощью PowerSync
Как мы ушли от архитектуры request-wait-response, перенесли чтения в локальную SQLite и сделали интерфейс быстрее с помощью PowerSync.
- PowerSync
- local-first
- SQLite
Медовый месяц любого MVP
Знакомая история: пока вы делаете MVP, всё летает.
Один пользователь, пустая база, быстрый сервер и простые экраны. Пользователь нажимает кнопку, фронтенд отправляет запрос, бэкенд отвечает, UI обновляется. Всё предсказуемо и понятно.
На этом этапе легко поверить, что архитектура выдержит рост. Запросы быстрые, таблицы маленькие, пользовательские сценарии простые. Любая форма сохраняется за долю секунды. Любой список открывается сразу.
Но потом продукт начинает жить взрослой жизнью.
Появляются длинные списки, сложные фильтры, аналитика, связанные сущности, мобильная версия, несколько устройств и увеличивается количество пользователей.
И вот уже вместо работы пользователь смотрит на лоадер. Открывает список и ждёт. Меняет поле и снова ждёт. С интернетом всё нормально, сервер вроде тоже живой, но продукт ощущается вязким.
Это неприятный момент. Особенно когда технически всё вроде бы сделано правильно.
Стандартная терапия
В такой ситуации мы обычно идём протоптанной тропой.
Проверяем индексы в PostgreSQL. Добавляем пагинацию. Кэшируем эндпоинты. Выносим тяжёлые расчёты. Смотрим EXPLAIN ANALYZE. Убираем лишние JOIN. Разделяем большие запросы на несколько маленьких. Оптимизируем сериализаторы. Добавляем debounce на фронтенде.
Это всё важно. И часто действительно помогает.
Но в нашем случае стало понятно, что проблема не только в медленном бэкенде. Проблема была в самой архитектуре запроса и ожидания.
Классическая схема выглядела так:
click -> request -> wait -> response -> update UIПока сеть и бэкенд быстрые, всё окей. Но стоит мобильному интернету моргнуть, серверу чуть дольше обрабатывать запрос или базе задуматься над тяжёлой выборкой, как интерфейс становится заложником ожидания.
Пользователь не может продолжить работу, пока приложение не получит ответ. Любое действие превращается в маленькую сделку с сетью.
В какой-то момент мы решили, что хватит это терпеть, и пошли другим путём.
Local first: когда данные всегда под рукой
Мы перешли к архитектуре, где основным источником данных для интерфейса стала локальная SQLite база на устройстве пользователя.
Важный дисклеймер: бэкенд никуда не делся.
Он всё так же отвечает за авторизацию, права доступа, бизнес правила, валидацию и долгосрочную согласованность данных. PostgreSQL остаётся центральным хранилищем. Но React больше не обязан обращаться к API каждый раз, когда нужно показать список, применить фильтр или обновить поле на экране.
Схема стала такой:
React UI -> Local SQLite -> PowerSync -> Backend -> PostgreSQLТеперь пользователь нажимает кнопку сохранения, запись сразу попадает в локальную базу, UI обновляется почти мгновенно, а PowerSync отправляет изменение на бэкенд в фоне.
click -> local write -> update UI -> sync in backgroundСеть всё ещё нужна. Но она больше не стоит между пользователем и интерфейсом.
Это главный сдвиг. Не просто ускорить отдельный запрос, а убрать ожидание сети из основного цикла работы пользователя.
Как это устроено внутри
Фронтенд работает с локальной SQLite через PowerSync. Компоненты не знают про API каждого экрана. Они читают данные через hooks или DAL слой, который выполняет SQL запросы к локальной базе.
Бэкенд при этом меняет роль. Он становится не слоем, который отдаёт JSON для каждого рендера, а местом, где проверяются права, ограничения, связи между сущностями и входящие операции из upload очереди.
PowerSync отвечает за синхронизацию. Он доставляет данные на клиент, поддерживает локальную SQLite и отправляет локальные изменения обратно.

Мы не скачиваем всю базу
Первый вопрос, который обычно возникает: не окажется ли вся база на устройстве пользователя.
Нет.
Важная часть PowerSync это partial replication. Клиент получает только те строки, к которым у пользователя есть доступ.
Например, если пользователь состоит в нескольких рабочих пространствах, он получает данные только по ним. Остальное на устройство не попадает.
Упрощённый пример sync_rules.yaml:
bucket_definitions:
by_workspace:
parameters: |
SELECT workspace_id
FROM workspace_memberships
WHERE user_id = request.user_id()
data:
- SELECT * FROM records WHERE workspace_id = bucket.workspace_id
- SELECT * FROM categories WHERE workspace_id = bucket.workspace_idЭто не ситуация, где мы скачали всё и спрятали лишнее на фронтенде. Лишние данные просто не синхронизируются.
Отсюда два больших плюса.
Первый: пользователь физически не получает чужие строки.
Второй: бэкенд и PostgreSQL меньше участвуют в обычных чтениях. Списки, сортировки, фильтры и часть аналитики работают локально.
Например, экран со списком может открываться обычным SQL запросом:
SELECT *
FROM records
WHERE workspace_id = ?
ORDER BY created_at DESC
LIMIT 50;Если нужен индекс, он тоже живёт локально:
CREATE INDEX records_workspace_created_at_idx
ON records (workspace_id, created_at);Это обычная база рядом с пользователем. Не кэш на всякий случай, а полноценный источник данных для интерфейса.
Именно здесь интерфейс начинает ощущаться быстрее. Открытие списка больше не зависит от round trip до сервера. Фильтр не превращается в новый API запрос. Сортировка не ждёт ответа от базы на другом конце сети. Часть аналитики можно считать прямо на устройстве.
Для пользователя это не выглядит как «мы оптимизировали запрос». Он просто видит другое поведение продукта: экран появляется сразу, изменение видно сразу, переходы становятся спокойнее. Лоадеры исчезают из мест, где они раньше казались неизбежными.
Бэкенд и PostgreSQL остаются важными. Они участвуют в синхронизации, первичной загрузке, проверке прав, сохранении данных. Но обычное чтение экрана больше не проходит через API каждый раз.
Отдельный токен для синхронизации
Мы разделили обычную авторизацию приложения и доступ к слою синхронизации.
Для PowerSync используется отдельный короткоживущий JWT. Клиент обращается к обычному API, бэкенд проверяет пользователя и выдаёт токен специально для sync слоя.
class GetPowerSyncToken(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
token = create_powersync_jwt(str(request.user.id))
return Response({
"token": token,
"powersync_url": settings.POWERSYNC_URL,
})PowerSync проверяет claims в этом токене и использует их при применении sync rules.
Такое разделение оказалось удобным. Обычная сессия приложения живёт своей жизнью. Синхронизация получает отдельный короткий пропуск.
Локальные мутации
Главный сдвиг для фронтенда: мы перестали воспринимать сохранение как немедленный POST на бэкенд.
Сначала меняется локальная база.
await powerSync.writeTransaction(async (tx) => {
await tx.execute(
`INSERT INTO records (id, workspace_id, amount, created_at)
VALUES (?, ?, ?, ?)`,
[id, workspaceId, amount, createdAt]
);
await tx.execute(
`UPDATE categories
SET usage_count = COALESCE(usage_count, 0) + 1
WHERE id = ?`,
[categoryId]
);
});В одной локальной транзакции можно обновить несколько связанных сущностей.
Создали запись. Пересчитали локальное состояние. UI сразу увидел консистентную картину.
Пользователю не нужно ждать подтверждение от сервера. Он видит результат действия сразу, а синхронизация догоняет состояние в фоне.
Это очень сильно меняет ощущение продукта. Даже если цифры производительности не выглядят драматично, субъективно приложение начинает восприниматься как гораздо более быстрое.
Upload это отдельный пайплайн
Offline меняет поведение пользователя.
Пользователь может несколько раз изменить одну и ту же запись до того, как приложение снова получит сеть.
update title
update amount
update category
update title againЕсли отправлять каждое промежуточное состояние на сервер, получится много лишнего шума. В большинстве случаев бэкенду нужна финальная версия строки, а не вся история того, как пользователь до неё дошёл.
Поэтому перед отправкой мы сжимаем upload очередь.
const transaction = await database.getNextCrudTransaction();
const byKey = new Map();
for (const item of transaction.crud || []) {
const key = `${item.table}::${item.id}`;
const previous = byKey.get(key);
byKey.set(
key,
previous ? mergeOperations(previous, item) : item
);
}
const batch = [...byKey.values()];
await postBatchWithRetries(uploadUrl, batch);
await transaction.complete();Мы группируем операции по строке и отправляем только то, что действительно нужно применить на сервере.
Меньше лишних операций. Меньше повторов. Меньше странных ситуаций при восстановлении сети.
Но тут есть важная деталь: upload очередь нужно проектировать как полноценную часть системы. Нужно понимать, какие ошибки можно повторять, какие нужно считать окончательными, как обрабатывать частичный успех и что делать с операцией, которая постоянно блокирует очередь.
Бэкенд всё равно главный
Local first не означает, что фронтенду можно доверять.
Да, пользователь сначала пишет данные локально. Да, UI обновляется сразу. Но бэкенд всё равно проверяет каждую операцию, которая прилетает из очереди.
for index, operation in enumerate(batch):
try:
with transaction.atomic():
action = operation["op"]
table = operation["table"]
row_id = operation["id"]
data = operation.get("data", {})
if action == "PUT":
apply_put(table, row_id, data)
elif action == "PATCH":
apply_patch(table, row_id, data)
elif action == "DELETE":
apply_delete(table, row_id)
else:
raise ValidationError("Unsupported operation")
except ValidationError as exc:
errors.append({
"index": index,
"table": operation.get("table"),
"id": operation.get("id"),
"retryable": False,
"detail": str(exc),
})Права доступа, лимиты, связи между сущностями, корректность полей, допустимость операции, всё это остаётся на сервере.
PowerSync помогает доставить изменения. Он не должен становиться обходным путём вокруг бизнес логики и безопасности.
Кроссплатформенность стала проще
Ещё один практический плюс: один код можно использовать на разных платформах.
В нашем случае один фронтенд подход работает для web, PWA, Android TWA и iOS WebView wrapper. Оболочки отличаются, но логика работы с данными остаётся общей.
Платформенные особенности никуда не исчезают. Storage, permissions, lifecycle, push уведомления, background behavior, всё это приходится учитывать. Особенно на мобильных платформах.
Но сам подход к данным не нужно переписывать заново под каждую платформу.
Чтение локальное. Запись локальная. Синхронизация фоновая.
Для пользователя это ближе к ощущению нативного приложения, даже если внутри работает web интерфейс.
Минимальный self hosted deployment
Такую архитектуру можно поднять через Docker Compose.
В минимальном виде нужны frontend, backend, PowerSync и PostgreSQL.
services:
frontend:
build:
context: ./frontend
ports:
- "4173:4173"
backend:
build:
context: ./backend
ports:
- "8000:8000"
powersync:
build:
context: ./powersync
command: ["start", "-r", "unified"]
ports:
- "7001:7001"
volumes:
- ./powersync/config:/config
postgres:
image: postgres:16
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: change_meКонфигурация PowerSync в упрощённом виде:
replication:
connections:
- type: postgresql
uri: !env PS_DATA_SOURCE_URI
sslmode: disable
storage:
type: postgresql
uri: !env PS_STORAGE_PG_URI
sslmode: disable
sync_rules:
path: sync_rules.yaml
client_auth:
jwks_uri: !env PS_JWKS_URL
audience:
- !env PS_AUDIENCE- PS_DATA_SOURCE_URI указывает на основную PostgreSQL базу.
- PS_STORAGE_PG_URI используется для storage самого PowerSync.
- PS_JWKS_URL нужен, чтобы PowerSync мог проверять JWT.
Обратная сторона
Если вы всё ещё сомневаетесь в local-first подходе, то правильно делаете.
Это не бесплатное ускорение приложения. Это архитектурный выбор, который решает одни проблемы и приносит другие.
Первая вещь, о которой нужно договориться: конфликты. Если два пользователя изменили одну и ту же строку без сети, нужно понимать, что делать при синхронизации. Иногда достаточно Last Write Wins. Иногда это плохой вариант, потому что последняя запись может затереть важные данные. В более сложных местах нужна доменная merge логика.
Вторая зона риска: миграции. Локальная база живёт на устройстве пользователя. Клиент может не открывать приложение месяц. За это время вы успеете изменить схему, добавить поля, переименовать таблицы или убрать старую колонку. Когда такой клиент вернётся, его локальная SQLite должна пережить новую реальность. Иногда достаточно обычной миграции. Иногда нужен recovery сценарий. Иногда проще аккуратно пересоздать локальное состояние и заново синхронизироваться.
Отдельная боль появляется вокруг sync_rules.yaml. Иногда строка есть в PostgreSQL, но не появляется на клиенте. Причина может быть в JWT, bucket, workspace_id, слишком узком правиле или данных, которые попали не в тот набор. Такие проблемы не всегда сложные, но они требуют дисциплины. Нужны хорошие логи.
И ещё есть ментальная сложность. Классическая API архитектура проще для понимания: нажали кнопку, отправили запрос, получили ответ, показали результат. В local first системе состояние живёт в нескольких местах. Есть локальная база. Есть upload очередь. Есть сервер. Есть репликация. Есть момент, когда локально пользователь уже видит изменение, а сервер ещё не принял его. С этим можно жить. Но это требует аккуратного проектирования и хороших инструментов отладки.
Оно того стоило
Для нас да.
Переход на PowerSync и local first подход сделал продукт не просто быстрее. Он изменил ощущение от работы с интерфейсом.
Пользователь нажимает кнопку и сразу видит результат. Список открывается без ожидания API. Фильтры не превращаются в серию запросов к серверу. Мобильное приложение спокойнее переживает нестабильную сеть.
Когда сеть перестаёт быть обязательным участником каждого действия, продукт начинает ощущаться иначе.
Мы внедрили этот подход в проекте:
В следующий раз расскажу, как мы поверх этой локальной базы прикрутили End-to-End Encryption, чтобы даже мы, разработчики, не видели, что хранят пользователи.