Load Shedding with TryAcquire: Fast Rejection Beats Slow Queuing
Acquire queuing just relocates the OOM. TryAcquire + HTTP 429 is real load shedding: fast rejection beats slow queuing when holding the resource is the problem.
Acquire queuing just relocates the OOM to the waiting area. TryAcquire + 429 is real shedding.
Acquire vs TryAcquire
- Acquire (blocking): over-limit callers wait in a FIFO queue. Each waiter holds a goroutine + a connection + memory. An unbounded queue just relocates the OOM.
- TryAcquire (non-blocking): immediate
false→ HTTP 429. This is load shedding, fast rejection that lets the caller retry elsewhere (keep-alive pins retries). - Choose shedding when holding the resource is itself the problem (for example, browser tabs). Choose a bounded queue when short bursts should be absorbed.
semaphore.Weighted Internals
Think of a parking lot with 6 spots (size: 6), 4 currently taken (cur: 4):
TryAcquire (non-blocking): check for an open spot. If one exists, take it. If not, leave immediately. Never waits in line.
Acquire (blocking): no open spot? Wait in line until someone leaves. Can give up early via context cancellation.
Release: leave, free up a spot. If anyone is waiting, let the next one in (FIFO order).
TryAcquire(1): Acquire(ctx, 1):
lock lock
remaining >= 1 remaining < 1?
&& no one queued? → join waiters, block on channel
→ cur++, return true (blocks until someone Releases)
(never blocks)
Release(1):
cur-- → wake next waiter in FIFO order
- Here
n=1(one spot at a time),size=6→ max 6 concurrent captures. - When TryAcquire returns
false, the handler returns 429 immediately — no queuing, no goroutine held, no memory spent. - The
waiters.Len()==0guard enforces fairness: newcomers cannot skip ahead of callers already waiting in line.
Role in This OOM
The screenshot service’s /capture uses slots.TryAcquire(1) to cap concurrent captures at 6. The 7th+ request gets an immediate 429. No new browser tab opens, and the browser sidecar’s memory stays bounded (shared resource blast radius).
A fast 429 is cheaper than a slow 500. Shed requests still have a chance; requests queued into an OOM are all lost.
References:
- https://github.com/golang/sync/blob/master/semaphore/semaphore.go
- https://pkg.go.dev/golang.org/x/sync/semaphore
- https://kupczynski.info/posts/tcp-proxy-concurrency-limits/
- https://jeffbailey.us/blog/2025/12/16/what-is-load-shedding/
Related: see shared resource blast radius and keep-alive pins retries, or go back to the series overview.