xpar.inInitializing
back to blogEngineering · Backend

Why Go and PostgreSQL Are the Backbone of High-Concurrency Systems.

29 May 20268 min readBy xpar

When you build platforms that serve fifty thousand concurrent users in a single campaign window, the stack you pick stops being a preference and starts being a constraint. The wrong choice will eat your team alive in firefights for the first six months. The right choice mostly gets out of your way.

At xpar, we've shipped systems on this stack at exactly that scale — most recently a fantasy football ad-campaign trading platform that handled peak loads of 50K+ users on Go + PostgreSQL + Redis. After enough of these builds, a pattern emerges: this pairing isn't trendy, it isn't novel, and it isn't going to make a CTO swoon at a conference. What it is, is deeply boring — in the best possible way.

Here's why we keep reaching for it.

The concurrency problem you actually have

When people say “high concurrency,” they usually mean one of three things:

  • High request throughput — thousands of small, cheap HTTP requests per second.
  • High simultaneous connection count — tens of thousands of long-lived connections (WebSockets, SSE, polling).
  • High contention on shared state — many writers fighting for the same rows or counters.

A surprising number of stacks ace the first two and quietly fall apart on the third. The third is where most systems actually break. Auction systems, trading platforms, inventory counters, leaderboards — these aren't problems of more requests, they're problems of many requests touching the same data.

Solve the first two without thinking about the third, and you'll discover the gap on launch day.

Why Go is the right tool for the request fan-out

Go was designed for exactly this shape of problem, and it shows.

Goroutines are not threads. A goroutine costs about 2 KB of stack at start, and the Go scheduler multiplexes millions of them onto a small pool of OS threads. You don't think about thread pools; you just write go handleRequest(...)and the runtime sorts it out. Practically, this means a single Go process can hold fifty thousand active connections without breaking a sweat — something you'd need ulimit tuning, a custom thread pool, and possibly a different framework to do in JVM or Node land.

Channels make backpressure explicit. When you have ten thousand inbound requests competing for a hundred outbound database connections, you don't want them to race. You want them to queue, with explicit timeouts and shed-load behavior when the queue gets too long. Channels with select plus context.Context give you those primitives without reaching for a library:

Go
select {
case dbPool <- req:
    // got a slot
case <-ctx.Done():
    // client gave up
case <-time.After(50 * time.Millisecond):
    // shed load — too busy
    metrics.Shed.Inc()
    http.Error(w, "busy", 503)
}

That's twelve lines, and it's the difference between a system that gracefully degrades under load and one that cascades into outage.

Compile to a single static binary. No JVM heap to tune. No npm dependency tree to audit at deploy time. You build, you ship a ~20 MB binary, you run. The deployment story is so simple it almost feels like cheating.

Garbage collection that doesn't ruin your night.Go's GC is concurrent, low-latency, and consistently produces pause times under a millisecond at the loads we work with. You can ship Go to production without becoming a JVM tuning expert.

Why PostgreSQL is the right tool for the contended state

Now for the harder half: the contended state. This is where PostgreSQL earns its keep.

MVCC means readers never block writers. Multi-version concurrency control is the killer feature that makes Postgres feel like a database from the future. A SELECT happening at the same moment as an UPDATEdoesn't wait. It sees the snapshot of the row as it was when the SELECT started. For an analytics dashboard reading the same tables a payment processor is writing to, this is the difference between “works” and “blocks everyone for ten seconds.”

Row-level locking when you actually need contention. When two writers genuinely contend for the same row — a bid in an auction, a stock decrement, a balance update — Postgres gives you precise control. SELECT ... FOR UPDATE and SELECT ... FOR UPDATE SKIP LOCKED are the two most underused features in backend engineering. The latter, in particular, is how you build a job queue inside Postgres without reaching for Kafka:

SQL
SELECT id, payload
FROM jobs
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;

Ten worker processes can hit that query simultaneously and each gets a different row. No external queue, no broker, no operational overhead.

Connection pooling is a solved problem. Postgres connections are heavy — each one is a process. Don't open thousands of them. Put PgBouncer in front, run it in transaction mode, and your Go app talks to PgBouncer over a tiny pool while PgBouncer multiplexes those onto the actual Postgres backend. Done. We routinely run Go apps with MaxOpenConns: 25 against PgBouncer that fronts a 200-connection Postgres pool, serving tens of thousands of QPS.

LISTEN / NOTIFY is the underrated real-time channel. When a row changes and you want to push the change to subscribed clients, you don't need Redis pub/sub or Kafka. NOTIFY channel_name, 'payload' inside a transaction, LISTEN channel_name in your Go service, and you have real-time event delivery with the transactional guarantees of Postgres. We use this for everything from order book updates to admin notifications.

JSONB gives you flexibility without giving up schema.You don't have to choose between “this is a strict relational system” and “this lets us add fields without a migration.” JSONB columns let you index, query, and partial-update structured blobs while the rest of your row stays tightly typed. The schema discipline is opt-in per column.

The pair, together

Here's the operational rhythm we see when these two work together at scale:

  • Go services hold the persistent connections (WebSocket, SSE) and fan them out across goroutines.
  • A bounded connection pool keeps PostgreSQL from being overwhelmed.
  • Reads use snapshot isolation; they're never blocked by writes.
  • Writes that need synchronization use FOR UPDATE on the smallest possible row scope.
  • Real-time fan-out uses LISTEN / NOTIFY for transactional consistency, Redis pub/sub for non-transactional broadcast.
  • Background work is a Postgres table with FOR UPDATE SKIP LOCKED workers — no external queue.

This stack scales vertically a long way before you need to think about sharding. On modern hardware, a single PostgreSQL instance comfortably handles tens of thousands of QPS. Most teams will outgrow their product before they outgrow this setup.

When not to use this stack

Honesty is part of the job, so:

  • You need globally distributed strong consistency. Postgres replicas are asynchronous. If you absolutely need writes acknowledged across three continents synchronously, you're in CockroachDB or Spanner territory.
  • Your workload is OLAP-shaped. Analytical queries scanning billions of rows aren't what Postgres was built for. Pair it with ClickHouse, BigQuery, or DuckDB for that side of the house.
  • You need sub-millisecond p99 for cached reads. Postgres can do single-digit milliseconds easily, but if you need 0.5 ms reads you're putting Redis in front of it anyway.
  • You're prototyping and reach is more important than correctness. Go's strict typing and Postgres's schema discipline both slow you down at the start. For a rapidly iterating prototype, a less ceremonious stack ships faster.

Closing

The case for Go + PostgreSQL isn't that they're exciting. It's that they're predictable under load. When the launch traffic actually arrives — when the campaign goes live, when the trading window opens, when the demo turns into a production rollout — predictable is what you want.

We've shipped this stack at 50K concurrent users in production. We'd ship it again tomorrow.

Work with us

Building something that has to hold up at scale?

Send a one-paragraph brief or book a 15-minute call. We come back inside 48 hours with an honest read on fit, scope and timeline.