Skip to content

What is SyncGuard?

SyncGuard is a distributed lock library for TypeScript that prevents race conditions in distributed systems. It provides a simple API for coordinating access to shared resources using Redis, PostgreSQL, or Firestore as the backend.

The Problem: Race Conditions in Distributed Systems

When multiple services or processes work on the same resource, race conditions happen:

typescript
// ❌ Without locks: payment processed twice
async function processPayment(paymentId: string) {
  const payment = await getPayment(paymentId);

  if (payment.status === "pending") {
    await chargeCard(payment.amount); // Process 1 charges
    await updateStatus(paymentId, "completed"); // Process 2 also charges!
  }
}

Two instances read status: 'pending' simultaneously. Both charge the card. Oops.

Why Database Transactions Aren't Enough

Database transactions don't help when the work involves external APIs (payment processors, emails, webhooks) or spans multiple databases.

The Solution: Distributed Locks

SyncGuard ensures only one process executes the critical section:

typescript
import { createLock } from "syncguard/firestore";

const lock = createLock(db);

// ✅ With locks: only one process wins
await lock(
  async () => {
    const payment = await getPayment(paymentId);
    if (payment.status === "pending") {
      await chargeCard(payment.amount);
      await updateStatus(paymentId, "completed");
    }
  },
  { key: `payment:${paymentId}`, ttlMs: 60000 },
);

The lock() function is a high-level wrapper for simple "run-this-exclusively" tasks. For more advanced use cases like rate limiting or conditional locking, you can use the core backend object directly.

The first process acquires the lock. Others wait or retry. Your customer gets charged once.

Key Features

  • 🔢 Fencing tokens — Monotonic counters prevent stale lock holders from corrupting data (see Fencing Tokens)
  • 🧹 Automatic cleanup — TTL-based expiration means locks release even if your process crashes
  • 🔐 Ownership tracking — Each lock gets a unique ID; only the owner can release or extend it
  • 🔄 Backend flexibility — Use Redis for speed, PostgreSQL for relational database infrastructure, or Firestore for serverless—same API, same guarantees
  • 🔁 Smart retries — Exponential backoff with jitter handles contention automatically
  • 💙 TypeScript-first — Compile-time type safety with capability inference

When to Use SyncGuard

✅ Perfect For

Preventing duplicate processing

typescript
// Ensure job runs exactly once across workers
await lock(() => processJob(jobId), { key: `job:${jobId}` });

Idempotency enforcement

typescript
// Webhook received multiple times? Handle once
await lock(() => handleWebhook(eventId), { key: `webhook:${eventId}` });

Rate limiting

typescript
// One request per user per minute
const result = await backend.acquire({
  key: `rate:${userId}`,
  ttlMs: 60000,
});
if (!result.ok) throw new Error("Rate limit exceeded");

Scheduled tasks

typescript
// Daily report generated by any instance, but only once
await lock(() => generateDailyReport(), {
  key: "cron:daily-report",
  ttlMs: 300000,
});

Resource coordination

typescript
// Prevent concurrent deploys
await lock(() => deployToProduction(), { key: "deploy:prod" });

When NOT to Use SyncGuard

❌ Anti-Patterns

High-Frequency Operations

Don't use locks for every API request or database query. Network round-trip per lock acquisition adds latency (~1-50ms). Consider optimistic concurrency or database locks instead.

Short critical sections

  • If your critical section is faster than acquiring the lock, you're doing it wrong
  • Example: incrementing a Redis counter (use INCR instead)

Within database transactions

  • Database transactions already provide isolation
  • Locks are for coordinating work outside a single database

As a queue replacement

  • Locks coordinate access, they don't distribute work
  • Use Redis Streams, SQS, Pub/Sub, etc. for job queues

Long-running workflows without extension

  • Locks expire via TTL (default: 30s)
  • For long tasks, use backend.extend() periodically or break into smaller steps

How It Works

  1. Acquire: Request lock with unique key (e.g., payment:123)
  2. Execute: Run your critical section while holding the lock
  3. Release: Free the lock for others (or let TTL expire automatically)

Atomic Operations Guarantee

SyncGuard uses atomic operations (Lua scripts for Redis, transactions for PostgreSQL and Firestore) to ensure:

  • No race window between checking and acquiring
  • Ownership verified before release/extend (see ADR-003)
  • Monotonic fence tokens for stale lock protection

Deep dive: See docs/specs/interface.md for TOCTOU protection requirements and atomicity guarantees.