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:
// ❌ 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:
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
// Ensure job runs exactly once across workers
await lock(() => processJob(jobId), { key: `job:${jobId}` });Idempotency enforcement
// Webhook received multiple times? Handle once
await lock(() => handleWebhook(eventId), { key: `webhook:${eventId}` });Rate limiting
// 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
// Daily report generated by any instance, but only once
await lock(() => generateDailyReport(), {
key: "cron:daily-report",
ttlMs: 300000,
});Resource coordination
// 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
INCRinstead)
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
- Acquire: Request lock with unique key (e.g.,
payment:123) - Execute: Run your critical section while holding the lock
- 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.