El Ecosistema Startup > Blog > Actualidad Startup > Testing Race Conditions en Postgres con Barriers | Guía 2026

Testing Race Conditions en Postgres con Barriers | Guía 2026

El problema invisible que puede arruinar tu producto

Imagina esto: tu startup procesa pagos, tu sistema registra dos créditos de $50 a una cuenta con $100 de saldo. El balance final debería ser $200, pero termina en $150. No hay error en los logs, no hay excepción lanzada, la base de datos hizo exactamente lo que le pediste. Acabas de perder $50 en una race condition.

Este tipo de bug es especialmente peligroso porque:

  • Ocurre solo bajo condiciones específicas de concurrencia
  • Tus tests unitarios tradicionales nunca lo detectarán
  • No deja rastro en los logs del sistema
  • Puede estar latente durante meses hasta que el tráfico aumenta

Para founders de startups tech, este escenario no es hipotético. Es una bomba de tiempo esperando el momento en que tu producto escale.

Anatomía de una race condition en Postgres

Una race condition (condición de carrera) ocurre cuando dos operaciones concurrentes leen el mismo valor obsoleto y luego ambas escriben basándose en él. Veamos el ejemplo del crédito bancario:

Proceso 1 lee balance: $100
Proceso 2 lee balance: $100
Ambos calculan nuevo balance: $150 (100 + 50)
Proceso 1 escribe: $150
Proceso 2 escribe: $150

El segundo UPDATE sobrescribe al primero. La segunda transacción de $50 desaparece sin dejar rastro. En un sistema financiero, esto significa clientes con balances incorrectos y cero evidencia de qué salió mal.

Por qué tus tests actuales no lo detectan

Tu suite de tests ejecuta una petición a la vez. La intercalación problemática nunca ocurre. Podrías ejecutar Promise.all([credit(1, 50), credit(1, 50)]) y el test pasaría, aunque tu código tenga el bug de concurrencia.

Algunas soluciones ingenuas que no funcionan:

  • Agregar sleep() entre queries: Test lento y flaky que a veces detecta el bug, a veces no
  • Ejecutar el test mil veces: No estás testeando concurrencia, estás tirando dados
  • Esperar a producción: El peor momento para descubrir race conditions

Lo que necesitas es forzar que dos operaciones lean el mismo valor obsoleto antes de que cualquiera escriba. Cada vez. De manera determinística.

Synchronization barriers: la solución elegante

Un synchronization barrier (barrera de sincronización) es un punto de coordinación para operaciones concurrentes. Le indicas cuántas tareas esperar. Cada tarea corre independientemente hasta llegar a la barrera, donde se detiene. Cuando la última tarea llega, todas se liberan simultáneamente.

Implementación básica en TypeScript:

function createBarrier(count: number) {
  let arrived = 0;
  const waiters: (() => void)[] = [];

  return async () => {
    arrived++;
    if (arrived === count) {
      waiters.forEach((resolve) => resolve());
    } else {
      await new Promise((resolve) => waiters.push(resolve));
    }
  };
}

Esta función mantiene un contador y una lista de promesas esperando. Cada llamada incrementa el contador. Si no es la última, espera. Cuando llega la última tarea, todas se liberan.

Aplicando la barrera al código real

Coloca una barrera entre la lectura y la escritura en tu código concurrente, y fuerzas exactamente la intercalación problemática: todas las tareas leen antes de que cualquiera escriba. Manufactura la race condition bajo demanda, de manera controlada.

Tres niveles de protección: experimento revelador

Aplicar la misma barrera bajo tres implementaciones diferentes revela verdades incómodas sobre transacciones y locks en Postgres.

Nivel 1: Queries sin protección

Sin transacción, solo un SELECT y un UPDATE con barrera entre ellos:

const barrier = createBarrier(2);

const credit = async (accountId: number, amount: number) => {
  const [row] = await db.execute(
    sql`SELECT balance FROM accounts WHERE id = ${accountId}`
  );
  
  await barrier(); // Ambas tareas esperan aquí
  
  const newBalance = row.balance + amount;
  await db.execute(
    sql`UPDATE accounts SET balance = ${newBalance} WHERE id = ${accountId}`
  );
};

Resultado: El test falla. Balance final: $150 en lugar de $200. La barrera fuerza el escenario exacto de race condition. Determinístico, sin trucos de timing.

Nivel 2: Agregando transacciones

Envolvemos la operación en una transacción:

const credit = async (accountId: number, amount: number) => {
  await db.transaction(async (tx) => {
    const [row] = await tx.execute(
      sql`SELECT balance FROM accounts WHERE id = ${accountId}`
    );
    await barrier();
    const newBalance = row.balance + amount;
    await tx.execute(
      sql`UPDATE accounts SET balance = ${newBalance} WHERE id = ${accountId}`
    );
  });
};

Resultado: El test todavía falla. Balance: $150.

Esta es la lección crítica: el nivel de aislamiento por defecto de Postgres es READ COMMITTED. Cada statement ve todos los datos committed antes de que ese statement empezara. Una transacción te da snapshot consistency por statement, no un write lock. La barrera acaba de probar que son cosas diferentes.

Nivel 3: Agregando locks explícitos

SELECT ... FOR UPDATE adquiere un lock a nivel de fila en tiempo de lectura. Otra transacción intentando lockear la misma fila se bloquea hasta que la primera haga commit:

const credit = async (accountId: number, amount: number) => {
  await db.transaction(async (tx) => {
    const [row] = await tx.execute(
      sql`SELECT balance FROM accounts WHERE id = ${accountId} FOR UPDATE`
    );
    await barrier();
    const newBalance = row.balance + amount;
    await tx.execute(
      sql`UPDATE accounts SET balance = ${newBalance} WHERE id = ${accountId}`
    );
  });
};

Resultado: El test se cuelga en un deadlock.

La primera tarea ejecuta SELECT ... FOR UPDATE y adquiere el lock. La segunda intenta el mismo query y se bloquea esperando el lock. Pero nunca llega a la barrera. La primera tarea está en la barrera esperando a la segunda. Deadlock clásico.

La solución: reposicionar la barrera

El deadlock no es un callejón sin salida, es información. El problema es la ubicación de la barrera. Muévela más temprano: después del BEGIN, antes del SELECT:

const credit = async (accountId: number, amount: number) => {
  await db.transaction(async (tx) => {
    await barrier(); // Ahora aquí
    
    const [row] = await tx.execute(
      sql`SELECT balance FROM accounts WHERE id = ${accountId} FOR UPDATE`
    );
    const newBalance = row.balance + amount;
    await tx.execute(
      sql`UPDATE accounts SET balance = ${newBalance} WHERE id = ${accountId}`
    );
  });
};

Resultado con FOR UPDATE: Test pasa. Balance: $200.
Resultado sin FOR UPDATE: Test falla. Balance: $150.

Esto es correcto. Un test de barrera válido debe pasar con el lock y fallar sin él. Si pasa en ambos casos, no está probando nada. Si falla en ambos, la barrera está mal posicionada.

Implementación práctica en tu startup

Testing contra base de datos real

Estos tests necesitan una instancia real de Postgres. Los mocks no tienen locks, transacciones ni contención real que reproducir. Para startups que buscan agilidad, Neon Testing es una solución moderna que provisiona instancias efímeras de Postgres y proporciona funciones como createBarrier listas para usar.

Inyección de barreras mediante hooks

Las barreras son infraestructura de test, no deben existir en código de producción. La solución es un hook opcional que se dispara en el punto correcto dentro de la transacción:

async function credit(
  accountId: number,
  amount: number,
  hooks?: { onTxBegin?: () => Promise | void }
) {
  await db.transaction(async (tx) => {
    if (hooks?.onTxBegin) {
      await hooks.onTxBegin();
    }
    const [row] = await tx.execute(
      sql`SELECT balance FROM accounts WHERE id = ${accountId} FOR UPDATE`
    );
    const newBalance = row.balance + amount;
    await tx.execute(
      sql`UPDATE accounts SET balance = ${newBalance} WHERE id = ${accountId}`
    );
  });
}

En producción, hooks es undefined y el check no cuesta nada. En tests, inyectas la barrera:

const barrier = createBarrier(2);
await Promise.all([
  credit(1, 50, { onTxBegin: barrier }),
  credit(1, 50, { onTxBegin: barrier })
]);

Cero overhead en producción, máxima confianza en tests.

Por qué esto importa para tu startup

Cada startup tech que escala enfrenta problemas de concurrencia eventualmente. La diferencia entre las que sobreviven y las que explotan está en detectar estos bugs antes de que lleguen a producción.

Sin barrier testing:

  • Cada refactor es una oportunidad para perder un lock crítico
  • Las race conditions solo aparecen bajo carga, cuando más duele
  • Debuggear en producción es 10x más caro que prevenir en desarrollo
  • La confianza de los usuarios se erosiona con cada inconsistencia de datos

Con barrier testing:

  • Las regresiones de concurrencia fallan en la máquina del developer
  • Los code reviews pueden verificar que los locks realmente funcionan
  • Tu suite de CI se convierte en una red de seguridad real
  • Escalas con confianza sabiendo que tu capa de datos es sólida

Conclusión

Las race conditions en bases de datos son el tipo de bug que destruye startups silenciosamente. No generan errores visibles, no aparecen en logs, y tus tests tradicionales nunca los detectarán. Las synchronization barriers cambian eso completamente.

Esta técnica te permite manufacturar condiciones de carrera de manera determinística, probar que tus locks realmente protegen tus datos, y detectar regresiones antes de que lleguen a producción. Para founders técnicos que construyen productos en Postgres, esto no es opcional: es la diferencia entre un sistema que escala y uno que explota bajo carga.

La próxima vez que escribas código que modifica datos concurrentemente, pregúntate: ¿Este código tiene un test de barrera? Si la respuesta es no, estás a un refactor de distancia de enviar un bug crítico a producción.

¿Construyendo sistemas que necesitan escalar sin romper? Únete gratis a Ecosistema Startup y conecta con founders que ya resolvieron estos desafíos de arquitectura.

Únete gratis ahora

Fuentes

  1. https://www.lirbank.com/harnessing-postgres-race-conditions (fuente original)
  2. https://www.postgresql.org/docs/current/transaction-iso.html
  3. https://wiki.postgresql.org/wiki/Lock_Monitoring
  4. https://www.npmjs.com/package/neon-testing
¿te gustó o sirvió lo que leíste?, Por favor, comparte.

Daily Shot: Tu ventaja táctica

Lo que pasó en las últimas 24 horas, resumido para que tú no tengas que filtrarlo.

Suscríbete para recibir cada mañana la curaduría definitiva del ecosistema startup e inversionista. Sin ruido ni rodeos, solo la información estratégica que necesitas para avanzar:

  • Venture Capital & Inversiones: Rondas, fondos y movimientos de capital.
  • IA & Tecnología: Tendencias, Web3 y herramientas de automatización.
  • Modelos de Negocio: Actualidad en SaaS, Fintech y Cripto.
  • Propósito: Erradicar el estancamiento informativo dándote claridad desde tu primer café.

Share to...