Sincronización en Segundo Plano y Arquitectura Offline-First en React Native con Expo SQLite y Supabase

En aplicaciones móviles destinadas a operaciones de campo, ventas en ruta, inspecciones técnicas o logística, la conectividad a Internet nunca está garantizada. Una aplicación que muestra spinners infinitos o bloquea la interfaz ante la falta de señal genera pérdidas operativas críticas. Diseñar con un enfoque Offline-First utilizando Expo SQLite como motor local y un motor de sincronización bidireccional hacia Supabase / PostgreSQL permite que la app funcione al 100% sin conexión y sincronice los cambios en segundo plano al recuperar red.

Principios de la Arquitectura Offline-First#
En una aplicación Offline-First, la interfaz de usuario nunca lee ni escribe directamente en la API remota. Todas las operaciones se realizan contra la base de datos local SQLite:
[UI React Native / Componentes]
▲
│ (Lectura / Escritura Inmediata <5ms)
▼
[Local DB: Expo SQLite]
├── Tablas de Entidades (Pedidos, Clientes, Items)
└── Tabla: sync_queue (Colas de mutaciones pendientes)
│
▼
[Background Sync Service / NetInfo Listener]
│ (Detecta conexión a Internet)
▼
[Supabase Remote Database]
- Validación de Timestamps
- Resolución de Conflictos (Last-Write-Wins)
1. Esquema de Base de Datos Local con Cola de Sincronización#
En Expo SQLite (expo-sqlite/next), creamos una tabla sync_queue para almacenar cada inserción, actualización o eliminación realizada fuera de línea:
// src/database/schema.ts
import * as SQLite from 'expo-sqlite';
export async function initDatabase() {
const db = await SQLite.openDatabaseAsync('app_offline.db');
await db.execAsync(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS orders (
id TEXT PRIMARY KEY NOT NULL,
client_name TEXT NOT NULL,
total_amount REAL NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
sync_status TEXT DEFAULT 'SYNCED' -- 'SYNCED', 'PENDING', 'CONFLICT'
);
CREATE TABLE IF NOT EXISTS sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_name TEXT NOT NULL,
operation TEXT NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE'
record_id TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT NOT NULL
);
`);
return db;
}
2. Repositorio Local con Encolado Automático de Mutaciones#
Cuando el usuario crea una orden en el móvil:
// src/services/orderService.ts
import { SQLiteDatabase } from 'expo-sqlite';
export async function createLocalOrder(
db: SQLiteDatabase,
order: { id: string; clientName: string; totalAmount: number }
) {
const now = new Date().toISOString();
await db.withTransactionAsync(async () => {
// 1. Insertar en tabla local para actualización visual inmediata
await db.runAsync(
`INSERT INTO orders (id, client_name, total_amount, status, created_at, updated_at, sync_status)
VALUES (?, ?, ?, 'PENDING', ?, ?, 'PENDING')`,
[order.id, order.clientName, order.totalAmount, now, now]
);
// 2. Registrar en la cola de sincronización
await db.runAsync(
`INSERT INTO sync_queue (table_name, operation, record_id, payload, created_at)
VALUES ('orders', 'INSERT', ?, ?, ?)`,
[order.id, JSON.stringify(order), now]
);
});
}
3. Motor de Sincronización en Segundo Plano con Expo Background Fetch#
Utilizamos expo-task-manager y expo-background-fetch junto con el listener de conectividad @react-native-community/netinfo:
// src/services/syncEngine.ts
import * as SQLite from 'expo-sqlite';
import NetInfo from '@react-native-community/netinfo';
import { supabase } from '../lib/supabase';
export async function processSyncQueue() {
const state = await NetInfo.fetch();
if (!state.isConnected) return;
const db = await SQLite.openDatabaseAsync('app_offline.db');
const pendingTasks = await db.getAllAsync<{
id: number;
table_name: string;
operation: string;
record_id: string;
payload: string;
}>('SELECT * FROM sync_queue ORDER BY id ASC LIMIT 50');
if (pendingTasks.length === 0) return;
for (const task of pendingTasks) {
const data = JSON.parse(task.payload);
try {
if (task.operation === 'INSERT' || task.operation === 'UPDATE') {
const { error } = await supabase
.from(task.table_name)
.upsert(data, { onConflict: 'id' });
if (error) throw error;
} else if (task.operation === 'DELETE') {
const { error } = await supabase
.from(task.table_name)
.delete()
.eq('id', task.record_id);
if (error) throw error;
}
// Marcar como sincronizado y eliminar de la cola
await db.runAsync('DELETE FROM sync_queue WHERE id = ?', [task.id]);
await db.runAsync(
`UPDATE ${task.table_name} SET sync_status = 'SYNCED' WHERE id = ?`,
[task.record_id]
);
} catch (err) {
console.error(`Error sincronizando tarea ${task.id}:`, err);
// Detener para mantener el orden cronológico estricto
break;
}
}
}
Estrategias de Resolución de Conflictos#
Cuando múltiples usuarios editan el mismo registro mientras están desconectados, se aplican las siguientes políticas:
- Last-Write-Wins (LWW) basado en Timestamp: El servidor acepta la versión con la fecha de modificación
updated_atmás reciente. - Column-Level Merging: Se combinan los campos modificados individualmente si no interfieren entre sí.
- Escalado a Aprobación Manual: Si existen discrepancias de inventario crítico, el registro se marca en estado
CONFLICTpara revisión por un supervisor.
Conclusión#
Construir aplicaciones con arquitectura Offline-First en React Native y Expo elimina la dependencia de conexiones inestables, entregando una experiencia fluida, robusta y de nivel empresarial.
¿Necesitas desarrollar una app móvil nativa para operaciones de campo o logística con sincronización offline? Descubre nuestro trabajo en Desarrollo de Apps Móviles o solicita una cotización para tu proyecto.
¿Te gustaría profundizar en estos temas?
Aprende sobre desarrollo de software, apps a medida, automatizaciones con N8N, Next.js y Cloud con casos reales.


