Kiran P K
← All writing
May 1, 2025·8 min read

Concurrency in Practice — A Job Claiming System That Works

ConcurrencyPostgresGolang

Concurrency issues don’t always show up during development — they usually surface when multiple users interact with your system simultaneously. For small-scale applications, this might not even be a concern. But if you’re a developer curious about how to handle concurrency the right way, this post is for you.

The use case

Let’s consider a job posting platform for freelance recruiters. Companies post jobs, and recruiters can claim the jobs they want to work on. To avoid overwhelming companies with too many applications, a single job can only be claimed by a limited number of recruiters — say, four. So we need a concurrency-safe mechanism to ensure no more than the allowed number of claims are made per job.

Some of you might think: “That sounds simple — we just query the number of claims for a job, and if it’s greater than or equal to 4, return an error, right?” Something like this:

count := db.Query(`SELECT COUNT(*) FROM job_claims WHERE job_id = ?`)
if count >= 4 {
    return error("Job fully claimed")
}
// proceed to insert claim

If so then you’re almost right — but not completely.

The problem lies in how web servers handle requests and how we think they do. We imagine our logic runs sequentially: a request comes in, checks the count, inserts the claim, done. In reality multiple requests hit the server at the same time, and each of them can execute that same SELECT COUNT(*) before any of them inserts. Two recruiters read the same count (say, 3), both believe the job is available, and both insert — a classic race condition.

Setting up

For this demo I’ll be using Golang and PostgreSQL. Here’s the folder structure:

|- db/
   |- db.go
|- handlers/
   |- jobs.go
|- .env
|- main.go
|- test.go
  • db/db.go — handles the PostgreSQL connection setup.
  • handlers/jobs.go — contains the business logic for claiming a job.
  • .env — stores the secrets you don’t want in the code.
  • main.go — entry file; starts the HTTP server and sets up routes.
  • test.go — simulates multiple concurrent recruiters claiming the same job.

We need four tables: companies (registered companies), jobs (postings), recruiter (signed-up recruiters), and job_claims (the many-to-many record of who claimed what).

CREATE TABLE public.job_claims (
  id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
  created_at TIMESTAMPTZ DEFAULT now(),
  job_id BIGINT NOT NULL,
  recruiter_id BIGINT NOT NULL,
  CONSTRAINT job_claims_job_id_recruiter_id_key UNIQUE (job_id, recruiter_id),
  CONSTRAINT job_claims_job_id_fkey FOREIGN KEY (job_id) REFERENCES jobs (id),
  CONSTRAINT job_claims_recruiter_id_fkey FOREIGN KEY (recruiter_id) REFERENCES recruiter (id)
);

Attempt 1: count, then insert

func ClaimJob(w http.ResponseWriter, r *http.Request) {
    jobId := r.URL.Query().Get("jobId")
    recruiterId := r.URL.Query().Get("recruiterId")

    var count int
    err := db.DB.QueryRow(`SELECT COUNT(*) FROM job_claims WHERE job_id = $1`, jobId).Scan(&count)
    if err != nil {
        http.Error(w, "Could not fetch claim count", http.StatusInternalServerError)
        return
    }

    if count >= 4 {
        http.Error(w, "Job fully claimed", http.StatusForbidden)
        return
    }

    _, err = db.DB.Exec(`INSERT INTO job_claims (job_id, recruiter_id) VALUES ($1, $2)`, jobId, recruiterId)
    if err != nil {
        http.Error(w, "Failed to claim the job", http.StatusInternalServerError)
        return
    }

    json.NewEncoder(w).Encode(map[string]string{"message": "Job claimed successfully"})
}

And the test fires five recruiters at the same job concurrently:

func main() {
    var wg sync.WaitGroup

    for i := 1; i <= 5; i++ {
        wg.Add(1)
        go func(rid int) {
            defer wg.Done()
            url := fmt.Sprintf("http://localhost:3000/claim-job?jobId=1&recruiterId=%d", rid)
            resp, err := http.Get(url)
            if err != nil {
                fmt.Printf("Recruiter %d error: %v\n", rid, err)
                return
            }
            defer resp.Body.Close()
            fmt.Printf("Recruiter %d: %s\n", rid, resp.Status)
        }(i)
    }

    wg.Wait()
}
Recruiter 4: 200 OK
Recruiter 1: 200 OK
Recruiter 2: 200 OK
Recruiter 5: 200 OK
Recruiter 3: 200 OK

Five claims instead of four. Each request checked the count before inserting, but none of them had visibility into the others happening at the same time.

Attempt 2: wrap it in a transaction

At this point you might think: why not make the whole thing atomic?

tx, err := db.DB.Begin()
if err != nil {
    http.Error(w, "Could not start transaction", http.StatusInternalServerError)
    return
}
defer tx.Rollback()

var count int
err = tx.QueryRow(`SELECT COUNT(*) FROM job_claims WHERE job_id = $1`, jobId).Scan(&count)
if count >= 4 {
    http.Error(w, "Job fully claimed", http.StatusForbidden)
    return
}

_, err = tx.Exec(`INSERT INTO job_claims (job_id, recruiter_id) VALUES ($1, $2)`, jobId, recruiterId)
err = tx.Commit()

Run the test again and all five still succeed. Unfortunately this doesn’t help either.

Transactions only isolate their own changes. Without explicitly locking rows, multiple transactions can read the same state and make decisions based on it — the same race condition.

The fix: lock the row

The solution is to lock the job row recruiters are contending for, so only one transaction can make the decision at a time. In Postgres that’s SELECT ... FOR UPDATE:

SELECT * FROM jobs WHERE id = $1 FOR UPDATE;
  • It locks the corresponding job row.
  • Other transactions trying to access the same row for update are blocked until the lock is released.
tx, err := db.DB.Begin()
defer tx.Rollback()

// Lock the job row
_, err = tx.Exec(`SELECT * FROM jobs WHERE id = $1 FOR UPDATE`, jobId)

// Has this recruiter already claimed it?
var exists bool
err = tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM job_claims WHERE job_id = $1 AND recruiter_id = $2)`, jobId, recruiterId).Scan(&exists)
if exists {
    http.Error(w, "Recruiter already claimed this job", http.StatusConflict)
    return
}

var count int
err = tx.QueryRow(`SELECT COUNT(*) FROM job_claims WHERE job_id = $1`, jobId).Scan(&count)
if count >= 4 {
    http.Error(w, "Job fully claimed", http.StatusForbidden)
    return
}

_, err = tx.Exec(`INSERT INTO job_claims (job_id, recruiter_id) VALUES ($1, $2)`, jobId, recruiterId)
err = tx.Commit()
Recruiter 3: 200 OK
Recruiter 5: 200 OK
Recruiter 2: 200 OK
Recruiter 4: 200 OK
Recruiter 1: 403 Forbidden

Only four claims succeed, the fifth fails. The race condition is gone.

Lock the decision, not the write

You might notice we lock a row in jobs but write to job_claims. That’s intentional, and it’s the important idea here: lock the decision-making resource, not just the table being modified.

  1. The job (in jobs) is the logical resource being contended for.
  2. The claims (in job_claims) are just how we track that allocation.

By locking the job row, every decision about whether that job can accept more claims is serialised — they happen one after another, not simultaneously. That’s what prevents several transactions from all seeing “3 claims” and all proceeding.

Another approach would be a separate job_claims_count table tracking the current count per job, and locking rows there instead — same guarantee, potentially less lock contention on the main jobs table.