All posts
·3 min read

Taming backpressure in a job scheduler

Jobs were being silently dropped under load. The fix wasn't more capacity — it was teaching the system to say 'not yet'.

Distributed SystemsGoReliability

We had a scheduler that worked beautifully in the demo and fell apart in production. Under bursty load, jobs would vanish — accepted by the API, never executed, no error. The kind of bug that erodes trust faster than an outage.

This is the story of finding the real constraint, and why the fix was to make the system slower on purpose.

The symptom vs. the problem

The instinct was "add workers." We did. It didn't help — it just moved the cliff a little further out. That's the tell that you're treating a symptom.

The actual problem: producers could enqueue faster than workers could drain, the in-memory buffer filled, and the oldest jobs were evicted to make room. Capacity wasn't the constraint. The absence of a feedback signal was.

The principle

A queue without backpressure isn't a buffer — it's a place where work goes to be quietly lost. Bounded queues force the question: what happens when it's full?

The shape of the fix

Instead of an unbounded queue, we used a credit-based scheme: workers hand out credits as they finish, and producers may only enqueue when they hold a credit. When the system is saturated, producers block (or get a fast 429) rather than overflowing a buffer.

Producer, bounded queue, and workers with a credit feedback loop
Credits flow back from workers to producers, closing the loop.

The core of the admission check is unglamorous — which is the point:

// Acquire blocks until a credit is available or the context is cancelled.
func (q *Queue) Enqueue(ctx context.Context, job Job) error {
	select {
	case <-ctx.Done():
		return ctx.Err()
	case q.credits <- struct{}{}: // take a credit (buffered channel)
		q.jobs <- job
		return nil
	}
}

Did it work?

Before, dropped jobs climbed with offered load and tail latency lied to us by staying flat (because dropping is "fast"). After, drops went to zero and latency told the truth — it rose under load, which is exactly what you want a healthy system to do.

Dropped jobs — beforeDropped jobs — after
01032053084101k2k4k8k16kDropped jobs — before: 0Dropped jobs — before: 4Dropped jobs — before: 31Dropped jobs — before: 120Dropped jobs — before: 410Dropped jobs — after: 0Dropped jobs — after: 0Dropped jobs — after: 0Dropped jobs — after: 0Dropped jobs — after: 0
Dropped jobs per minute as offered load increases.

Outcome

Zero dropped jobs through 16k RPS, and — counterintuitively — happier users, because a clear "retry in a moment" beats a silent disappearance every time.

What I took away

The whole change was a few dozen lines. Most of the work was understanding the problem well enough to make it that small.

Newsletter

Enjoyed this? Get the next one by email.

Occasional writing on water, modelling, and machine learning. No spam, unsubscribe anytime.

Back to all posts