Como construí um app mobile offline-first para gerenciar check-ins de milhares de pessoas em eventos — e por que cada decisão de arquitetura nasceu de um problema real
Imagina o cenário: um evento corporativo com 2.000 participantes. Staff espalhado por checkpoints de atividades, transporte, palestras, brindes, bagagem. Cada pessoa precisa ter seu check-in registrado em tempo real. O problema? O Wi-Fi do centro de convenções é instável, a rede 4G oscila, e se o app trava, o evento não espera.
Foi esse o desafio que me levou a construir um app React Native com Expo que precisou ser pensado do zero para funcionar mesmo quando a internet decide não colaborar.
Vou compartilhar as decisões técnicas e os problemas reais que cada uma resolve.
A Arquitetura
A estrutura do projeto foi organizada para separar responsabilidades de forma clara. Cada camada tem um papel bem definido:
/majoo-staff
├── /app # Rotas (Expo Router - file-based)
│ ├── _layout.tsx # Layout raiz: tema, auth, providers
│ ├── (unauthenticated)/ # Login, recuperação de senha
│ └── (authenticated)/ # Telas protegidas
│ ├── Home/ # Dashboard com categorias
│ ├── checkpoints/
│ │ ├── [type]/index.tsx # Lista por categoria
│ │ └── [type]/[id]/ # Detalhe + lista de usuários
│ │ └── qrcode/ # Scanner QR
│ ├── search/ # Busca global
│ ├── checkins/ # Histórico
│ └── reports/ # Relatórios com export PDF
│
├── /components # UI reutilizável
│ ├── /Button # Variantes (primary, float, checkin)
│ ├── /Input # Text, search, underline
│ ├── /Layout # Headers, Footers, Profile
│ ├── /Modal, /Loader, /Hr
│ ├── ThemedText.tsx
│ └── ThemedView.tsx
│
├── /hooks # Lógica de negócio encapsulada
│ ├── useApi.ts # Wrapper HTTP com Bearer token
│ ├── useApplications.ts # Gestão de eventos + importação
│ ├── useCheckpoints.ts # Check-ins + sincronização
│ └── useReports.tsx # Geração de PDF
│
├── /contexts
│ └── AuthContext.tsx # Estado global: user, app, auth
│
├── /models # Camada de dados (SQLite)
│ ├── useCheckin.ts # CRUD de check-ins locais
│ └── useUsers.ts # Queries com JOINs relacionais
│
├── /utils
│ ├── db.js # Schema SQLite + índices
│ ├── types.ts # Tipos TypeScript
│ ├── helpers.ts # Criptografia, storage, formatação
│ └── validations.ts # Validações de entrada
│
└── /constants
├── CheckpointTypes.tsx # Categorias com ícones
└── Colors.ts # Tema claro/escuro
A decisão de usar Expo Router com route groups ((authenticated) e (unauthenticated)) resolve um problema clássico: o guard de autenticação vive no layout, não espalhado por cada tela. Se o usuário não está logado, o redirect acontece em um único ponto.
Os hooks customizados (useCheckpoints, useApplications, useReports) funcionam como a camada de serviço do app. Eles encapsulam API, banco local e lógica de negócio — os componentes de tela ficam limpos, só consomem dados e renderizam.
Os models (useCheckin, useUsers) isolam o acesso ao SQLite. Nenhum componente faz query direta no banco. Isso cria uma camada de abstração que permite trocar a estratégia de persistência sem reescrever telas.
Offline-First: onde o app realmente se diferencia
Como requisito, o app deveria funcionar independente do status da conexão. É muito comum que em eventos e locais mais remotos não tenha uma conexão estável, então era muito necessário que o staff conseguisse ter acesso a lista em fazer checkin mesmo que não houvesse uma conexão.
A solução foi inverter a lógica: o banco local é a fonte de verdade, a API é apenas o mecanismo de sincronização.
1. Importação de dados com paginação e transações atômicas
Quando o staff se conecta a um evento, o app importa toda a base de usuários da API para o SQLite local. A importação é paginada e recursiva, processando cada página dentro de uma transação atômica:
const saveUsers = async (page = 1) => {
// Busca uma página de usuários da API
const response = await request(`/users?page=${page}`);
// Transação atômica: ou salva tudo da página, ou não salva nada
database.withTransactionSync(() => {
users.map(async (user) => {
// Verifica se o usuário já existe no banco local
const exist = // ... consulta por ID
if (!exist) {
// Usuário novo: insere com todo o histórico de check-ins da API
} else {
// Usuário já existe: faz merge dos check-ins novos
// sem sobrescrever os que foram feitos offline
}
});
});
// Se ainda tem páginas, chama recursivamente até importar tudo
if (response.data.last_page > page) {
return saveUsers(page + 1);
}
}
O merge inteligente garante que check-ins feitos offline nunca são sobrescritos quando a base é atualizada do servidor. Se o staff fez 50 check-ins sem internet e depois importa a base atualizada, nenhum desses 50 registros se perde.
2. Check-in local imediato — zero dependência de rede
Quando o staff registra um check-in (manual ou via QR Code criptografado com AES), o dado vai direto pro SQLite — sem esperar resposta da API:
const checkin = async (checkpoint_id, userId) => {
// Verifica se já fez check-in nesse checkpoint (evita duplicidade)
const exist = // ... consulta no banco local
if (exist) return 'checked';
// Salva direto no SQLite com synced = 0 (pendente de envio)
// Nenhuma chamada de rede acontece aqui — feedback instantâneo
const saved = // ... insert no banco local
if (saved) {
// Tenta sincronizar em background — não bloqueia o operador
sync();
}
return status;
}
O feedback pro operador é instantâneo. O check-in aparece na lista na hora. A rede não é bloqueante. Se não tem internet, o dado fica na fila local com synced = 0 e será enviado quando a conexão voltar.
3. Sincronização inteligente com drain recursivo
O sync() é o coração do sistema offline:
const sync = async () => {
// Só tenta sincronizar se tiver internet
const network = await Network.getNetworkStateAsync();
if (!network.isConnected) return;
// Busca check-ins pendentes em lotes de 20
const checkins = // ... SELECT WHERE synced = 0 LIMIT 20
const count = // ... COUNT total de pendentes
if (checkins.length < 1) return;
// Envia o lote inteiro pro servidor
const response = // ... POST /checkins
if (response.status) {
// Caso a requisição seja efetuada com sucesso
// recebemos um feedback do servidor informando
// quais os checkins foram sincronizados.
// Caso algum em específico falhe,
// ele não volta na lista e continua na fila
// para ser sincronizado no futuro.
for (let i in response.data) {
if (response.data[i].id) {
// Faz update do checkin para sync = 1
}
}
// Se ainda tem mais pendentes, agenda outra rodada em 500ms
// Esvazia a fila progressivamente sem travar a UI
if (count.total >= 20) {
setTimeout(sync, 500);
}
}
}
O ponto-chave: o sync roda em lotes de 20 com intervalo de 500ms entre eles. Isso garante que mesmo com centenas de check-ins acumulados offline, a fila é esvaziada progressivamente sem travar a UI do operador.
4. Modelo de dados relacional otimizado
O banco local usa duas tabelas com índices estratégicos para as queries mais frequentes:
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT,
email TEXT,
document TEXT,
application_id INTEGER NOT NULL, -- A qual evento pertence
checkin TEXT -- Histórico de check-ins (JSON da API)
-- ...
);
CREATE TABLE checkins (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER, -- Quem fez check-in
checkpoint_id INTEGER, -- Em qual checkpoint
synced INTEGER DEFAULT 0, -- 0 = pendente | 1 = enviado ao servidor
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY(user_id) REFERENCES users(id)
-- ...
);
-- Índices para acelerar as buscas mais comuns
CREATE INDEX application_id_idx ON users(application_id);
CREATE INDEX user_id_idx ON checkins(user_id);
CREATE INDEX checkpoint_id_idx ON checkins(checkpoint_id);
CREATE INDEX synced_idx ON checkins(synced);
A flag synced é o coração do mecanismo offline: 0 significa "registrado localmente, aguardando envio", 1 significa "confirmado pelo servidor". Buscar "todos os usuários pendentes no checkpoint X" é um simples LEFT JOIN. SQL puro, performático, confiável.
5. Cache com fallback automático em todas as camadas
O padrão se repete em toda a camada de dados:
const get = async (type) => {
// Tenta buscar da API
const response = // ... GET /checkpoints
if (response.status) {
// API respondeu: salva no cache local e retorna dados frescos
} else {
// API falhou (sem internet, timeout): busca do cache local
}
// O app nunca retorna vazio — sempre tem um fallback
return [];
}
Isso vale para listagem de eventos, checkpoints, categorias, identificações. O app nunca mostra tela vazia por falta de rede — sempre tem um fallback local.
Conclusão
Construir um app offline-first não é apenas adicionar cache — é mudar a arquitetura. O banco local passa a ser a fonte de verdade, enquanto a API vira apenas um mecanismo de sincronização.
Se você está construindo apps que precisam funcionar em cenários de conectividade instável — eventos, campo, indústria — invista tempo no design do seu mecanismo de sync. É lá que mora a diferença entre um app que deveria funcionar e um que funciona de verdade.
Top comments (0)