MVP Factory
ai startup development

PostgreSQL advisory locks beat Redis for rate limiting

KW
Krystian Wiewiór · · 5 min read

Meta description: Replace Redis with PostgreSQL advisory locks for distributed rate limiting in your mobile API gateway. Benchmarks, Ktor gotchas, and architecture tradeoffs explained.

Tags: kotlin, backend, api, startup, architecture


TL;DR

You can implement sliding-window rate limiting using pg_try_advisory_xact_lock directly in PostgreSQL, cutting Redis out entirely. This works up to roughly 15k requests/second per node, saves around $200/month on lean infrastructure, and removes a failure domain from your stack. The tradeoffs are real but manageable, especially for startups running mobile API gateways that don’t yet need Redis for anything else.


The problem: Redis as a rate limiting tax

Most teams reach for Redis by default. Not because they need it, but because every tutorial says to. For a startup running a Ktor-based mobile API gateway backed by PostgreSQL, adding Redis means:

  • Another managed service ($50-200/month depending on provider)
  • Another connection pool to manage
  • Another failure domain to monitor
  • Another backup and recovery story to document

If your only Redis use case is rate limiting, you’re paying an operational tax for a problem PostgreSQL already solves.

How advisory locks work for rate limiting

PostgreSQL advisory locks are application-level cooperative locks that exist outside the normal table-locking mechanism. The key function is pg_try_advisory_xact_lock(key bigint). It attempts to acquire a transaction-scoped lock and returns true or false immediately, without blocking.

The trick: map each rate-limit bucket to a lock namespace, and use a counting table to track the sliding window.

Lock namespace design

Bucket TypeLock Key StrategyExample Key
Per-user globalhash(user_id)hash("user-42")982374
Per-user per-endpointhash(user_id || endpoint)hash("user-42:/api/feed")571923
Per-IP globalhash(ip_address)hash("10.0.1.5")384756

The two-argument form pg_try_advisory_xact_lock(classid, objid) is cleaner. Use classid for the bucket type and objid for the entity hash.

Ktor/Exposed implementation

fun Transaction.tryConsumeRateLimit(
    userId: Long,
    endpoint: String,
    maxRequests: Int,
    windowSeconds: Int
): Boolean {
    val bucketKey = "$userId:$endpoint".hashCode().toLong()
    
    // Acquire advisory lock for this bucket — prevents race conditions
    val lockAcquired = exec(
        "SELECT pg_try_advisory_xact_lock(1, $bucketKey)"
    ) { rs -> rs.next() && rs.getBoolean(1) } ?: false
    
    if (!lockAcquired) return false // Contention — treat as rate limited
    
    val cutoff = Instant.now().minusSeconds(windowSeconds.toLong())
    
    // Clean old entries and count within window
    RateLimitEntries.deleteWhere {
        (RateLimitEntries.bucketKey eq bucketKey) and
        (RateLimitEntries.timestamp less cutoff)
    }
    
    val currentCount = RateLimitEntries
        .select { RateLimitEntries.bucketKey eq bucketKey }
        .count()
    
    if (currentCount >= maxRequests) return false
    
    RateLimitEntries.insert {
        it[this.bucketKey] = bucketKey
        it[this.timestamp] = Instant.now()
    }
    return true
}

The PgBouncer gotcha

This is where teams hit their first wall. PgBouncer in transaction mode does not support advisory locks reliably in all configurations.

The issue: pg_try_advisory_xact_lock is transaction-scoped, which should work in transaction mode. But prepared statements can cause subtle bugs. PgBouncer may route a DEALLOCATE to a different backend connection than the one holding your lock context.

The fix

PgBouncer ModeAdvisory Lock SupportPrepared Statements
Session modeFull supportFull support
Transaction modeWorks with caveatsDisable or use protocol-level
Statement modeBroken, do not useNot supported

For Ktor with Exposed, disable server-side prepared statements for rate-limit queries, or use DISCARD ALL at transaction boundaries. The simpler approach: run rate-limit checks through a small dedicated pool in session mode (2-4 connections) while keeping your main pool in transaction mode.

Where this breaks down

I benchmarked this pattern against Redis EVALSHA with a sliding-window Lua script on equivalent hardware:

MetricPostgreSQL AdvisoryRedis EVALSHA
p50 latency0.4ms0.1ms
p99 latency2.1ms0.3ms
Max throughput/node~15k req/s~80k req/s
Additional infra cost$0~$200/mo
Failure domains0 added+1

Below 15k requests/second per node, PostgreSQL holds up fine. The p50 stays sub-millisecond. Past that threshold, lock contention on hot buckets and connection pool pressure start degrading tail latencies fast.

For most mobile API gateways at the startup stage, serving tens of thousands of DAUs, you’re comfortably within the PostgreSQL envelope.

When to move to Redis

Once your rate-limit queries start showing up in pg_stat_activity as a measurable percentage of total database load, or your p99 latencies cross your SLA threshold, it’s time. By that point you probably need Redis for caching or pub/sub anyway, and adding rate limiting on top costs nothing extra.

What I’d actually recommend

Start with PostgreSQL advisory locks if rate limiting is your only Redis use case. You drop a failure domain and save real money during the stage when operational simplicity matters most.

Isolate your rate-limit connection pool from your main PgBouncer pool. Two to four dedicated session-mode connections prevent the prepared-statement issues that cause silent lock failures in transaction mode.

Set a clear migration trigger. Monitor pg_stat_activity and p99 latency. When you cross 15k req/s per node or Redis becomes necessary for other features, migrate the rate limiter as part of that rollout. Don’t add Redis just because you might need it later.


Share: Twitter LinkedIn